読者です 読者をやめる 読者になる 読者になる

ぴよぴよエンジニアの日記

クラウドベンダーに勤める見習いSEの日記です。発言は私自身の見解であり、必ずしも所属組織の立場、戦略、意見を代表するものではありません。

Custom Renderer で CardView を作ってみた

先日のハンズオンで勉強した Custom Renderer を使って CardView を作ってみました.


santea.hateblo.jp

ハンズオンの感想はこちら.


f:id:Santea:20160819162813p:plain

こちらが実装結果です.



PCL プロジェクト

using System;
using Xamarin.Forms;

namespace NakayokunaruHandsOn
{
	public class CustomCardView : View
	{
		#region ImageUrl BindableProperty
		public static readonly BindableProperty ImageUrlProperty =
			BindableProperty.Create(nameof(ImageUrl), typeof(string), typeof(CustomCardView), "http://img.animate.tv/news/visual/2016/1462453808_2_4_1d30912af59fc38e43bc5b080ddd6623.jpg",
				propertyChanged: (bindable, oldValue, newValue) =>
					((CustomCardView)bindable).ImageUrl = (string)newValue);

		public string ImageUrl
		{
			get { return (string)GetValue(ImageUrlProperty); }
			set { SetValue(ImageUrlProperty, value); }
		}
		#endregion

		#region Text BindableProperty
		public static readonly BindableProperty TextProperty =
			BindableProperty.Create(nameof(Text), typeof(string), typeof(CustomCardView), "エミリア《きみ》を見てる。" + Environment.NewLine + "レム《きみ》が見てる。" + Environment.NewLine + "だから、俯かない。",
				propertyChanged: (bindable, oldValue, newValue) =>
					((CustomCardView)bindable).Text = (string)newValue);

		public string Text
		{
			get { return (string)GetValue(TextProperty); }
			set { SetValue(TextProperty, value); }
		}
		#endregion

		#region OnClicked BindableProperty
		public static readonly BindableProperty OnClickedProperty =
			BindableProperty.Create(nameof(OnClicked), typeof(EventHandler), typeof(CustomCardView),  null,
				propertyChanged: (bindable, oldValue, newValue) =>
					((CustomCardView)bindable).OnClicked = (EventHandler)newValue);

		public EventHandler OnClicked
		{
			get { return (EventHandler)GetValue(OnClickedProperty); }
			set { SetValue(OnClickedProperty, value); }
		}
		#endregion
	}
}

View を継承した CustomCardView です.

CardView の Element としては 画像のURL(string) と、表示するテキスト(string)、そして Button のクリックイベント(EventHandler) があります.



<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
	xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
        xmlns:local="clr-namespace:NakayokunaruHandsOn"
        Title="CustomCardView"
        x:Class="NakayokunaruHandsOn.CustomCardViewPage" 
	Padding="0,10,0,0">

    <StackLayout HorizontalOptions="FillAndExpand"
			VerticalOptions="CenterAndExpand">

		<local:CustomCardView x:Name="customCardView"/>
        <Button Text="Next Character" Clicked="OnClicked"/>

    </StackLayout>
</ContentPage>
using System;
using System.Collections.Generic;

using Xamarin.Forms;

namespace NakayokunaruHandsOn
{
	public partial class CustomCardViewPage : ContentPage
	{
		private readonly string[] ImageUrls = { "http://img.animate.tv/news/visual/2016/1462453808_2_4_1d30912af59fc38e43bc5b080ddd6623.jpg",
			"http://img.animate.tv/news/visual/2016/1464248797_2_22_dcc3d1fc3c2057b7355b1aec00d6640f.jpg",
			"http://img.animate.tv/news/visual/2016/1466085974_1_10_124dbe5ac96045c64ed4af52fcc6fc4a.jpg",
			"http://img.animate.tv/news/visual/2016/1462453808_1_4_ebef70b77e429591a59408a0fa84c0a7.jpg" 
		};

		private readonly string[] Serifs = { "エミリア《きみ》を見てる。" + Environment.NewLine + "レム《きみ》が見てる。" + Environment.NewLine + "だから、俯かない。",
			"ご褒美は頑張った子にだけ与えられるから" + Environment.NewLine + "ご褒美なのです",
			"鬼がかってますね",
			"ラムもラムの素直なところは美点だと" + Environment.NewLine + "思っているわ"
		};

		public CustomCardViewPage()
		{
			InitializeComponent();

			customCardView.OnClicked = OnClicked;
		}

		int count = 0;

		private void OnClicked(object sender, EventArgs e)
		{
			count++;
			customCardView.ImageUrl = ImageUrls[count % 4];
			customCardView.Text = Serifs[count % 4];
		}
	}
}

CustomCardView 用の、ContentPage を継承した CustomCardViewPageです.

Androidプロジェクト、iOSプロジェクト共通で用いることになる string は System.Environment.NewLine で改行してあります.

また、CustomCardView の OnClicked イベントには、ContentPage のクリックイベントを割り当てています.一般的には画面遷移などのイベントを割り当てるかと思います.


以上が PCL プロジェクトの実装です.



Android プロジェクト

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">
    <android.support.v7.widget.CardView xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:cardCornerRadius="2dp"
        app:cardPreventCornerOverlap="false"
        app:cardUseCompatPadding="true"
        android:id="@+id/cardView"
        android:layout_marginRight="6dp"
        android:layout_marginLeft="6dp">
    <!-- カードに載せる情報 -->
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            android:id="@+id/cardRelative">
            <FrameLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content">
                <ProgressBar
                    android:layout_height="wrap_content"
                    android:layout_width="wrap_content"
                    style="?android:attr/progressBarStyleLarge"
                    android:layout_gravity="center" />
                <ImageView
                    android:layout_width="match_parent"
                    android:layout_height="220dp"
                    android:scaleType="fitXY"
                    android:id="@+id/imageView"
                    android:adjustViewBounds="true" />
            </FrameLayout>
            <TextView
                android:id="@+id/textView"
                android:textSize="16sp"
                android:gravity="center"
                android:minLines="3"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="10dp"
                android:layout_marginBottom="10dp"
                android:layout_marginLeft="10dp"
                android:layout_marginRight="10dp" />
            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal">
                <android.support.v7.widget.AppCompatButton
                    android:id="@+id/buttonShare"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    style="@style/Widget.AppCompat.Button.Borderless.Colored"
                    android:text="SHARE" />
                <android.support.v7.widget.AppCompatButton
                    android:id="@+id/buttonExplore"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    style="@style/Widget.AppCompat.Button.Borderless.Colored"
                    android:text="EXPLORE" />
            </LinearLayout>
        </LinearLayout>
    </android.support.v7.widget.CardView>
</LinearLayout>

Android 用の レイアウトです.

android.support.v7.widget.CardView を用いて カードを表現しています.Android は CardViewがあるので特に工夫なく作れます.

Button はフラットボタンを採用して、style="@style/Widget.AppCompat.Button.Borderless.Colored" としています.



using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Threading.Tasks;
using Android.Graphics;
using Android.Views;
using Android.Widget;
using Java.IO;
using Java.Net;
using Xamarin.Forms;
using Xamarin.Forms.Platform.Android;

[assembly: ExportRenderer(typeof(NakayokunaruHandsOn.CustomCardView), typeof(NakayokunaruHandsOn.Droid.CustomCardViewRenderer))]
namespace NakayokunaruHandsOn.Droid
{
	public class CustomCardViewRenderer : ViewRenderer<CustomCardView, Android.Views.View>
	{
		private ImageView imageView;
		private TextView textView;
		private Android.Widget.Button buttonShare;
		private Android.Widget.Button buttonExplore;
		private EventHandler onClicked;

		protected override void OnElementChanged(ElementChangedEventArgs<CustomCardView> e)
		{
			if (Control == null)
			{
				var inflater = LayoutInflater.From(Context);
				var view = inflater.Inflate(Resource.Layout.CustomCardView, null);

				imageView = view.FindViewById<ImageView>(Resource.Id.imageView);
				textView = view.FindViewById<TextView>(Resource.Id.textView);
				buttonShare = view.FindViewById<Android.Widget.Button>(Resource.Id.buttonShare);
				buttonExplore = view.FindViewById<Android.Widget.Button>(Resource.Id.buttonExplore);

				SetNativeControl(view);
			}

			if (e.NewElement != null)
			{
				UpdateImage();
				UpdateText();
				UpdateOnClicked();
			}

			base.OnElementChanged(e);
		}

		protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
		{
			base.OnElementPropertyChanged(sender, e);
			imageView.SetImageBitmap(null);

			if (e.PropertyName == CustomCardView.ImageUrlProperty.PropertyName)
			{
				UpdateImage();
			}
			if (e.PropertyName == CustomCardView.TextProperty.PropertyName)
			{
				UpdateText();
			}
			if (e.PropertyName == CustomCardView.OnClickedProperty.PropertyName)
			{
				UpdateOnClicked();
			}
		}

		private async void UpdateImage()
		{
			Bitmap bitmap = null;

			await Task.Run(() =>
			{
				try
				{
					var url = new URL(Element.ImageUrl);
					var inputStream = url.OpenStream();
					bitmap = BitmapFactory.DecodeStream(inputStream);
					inputStream.Close();
				}
				catch (IOException e)
				{
					e.PrintStackTrace();
				}
			});

			imageView.SetImageBitmap(bitmap);
		}

		private void UpdateText()
		{
			textView.Text = Element.Text;
		}

		private void UpdateOnClicked()
		{
			onClicked = Element.OnClicked;
			buttonShare.Click += onClicked;
			buttonExplore.Click += onClicked;
		}

		protected override void Dispose(bool disposing)
		{
			if (Control != null)
			{
				buttonShare.Click -= onClicked;
				buttonExplore.Click -= onClicked;
			}
			base.Dispose(disposing);
		}

	}
}

Android の Custom Renderer です.

LayoutInflater を用いて .xml から View を生成しています.

Image は async/await で取得して、ImageView にセットしています.ImageView の Height を決め打ちしてしまったのでサイズの更新はしていません.


以上がAndroid プロジェクトの実装です.



iOS プロジェクト

f:id:Santea:20160819160306p:plain

iOS 用のレイアウトです.

SuperView の直下に CardView の役割をする View を置いています.

UIImageView と UILabel は Height を指定しています.



using System.ComponentModel;
using Xamarin.Forms;
using Xamarin.Forms.Platform.iOS;
using UIKit;
using Foundation;
using ObjCRuntime;
using System;
using System.Threading.Tasks;

// Xamarin.FormsコントロールとRendererの対応を宣言
[assembly: ExportRenderer(typeof(NakayokunaruHandsOn.CustomCardView), typeof(NakayokunaruHandsOn.iOS.CustomCardViewRenderer))]

namespace NakayokunaruHandsOn.iOS
{
	public class CustomCardViewRenderer : ViewRenderer<CustomCardView, UIView>
	{
		private UIView nativeControl;
		private UIView cardView;
		private UIImageView imageView;
		private UILabel label;
		private UIView buttonView;
		private UIButton buttonShare;
		private UIButton buttonExplore;

		private EventHandler onClick;

		protected override void OnElementChanged(ElementChangedEventArgs<CustomCardView> e)
		{
			if (Control == null)
			{
				var array = NSBundle.MainBundle.LoadNib("CustomCardView", null, null);
				nativeControl =  Runtime.GetNSObject<UIView>(array.ValueAt(0));

				cardView = (UIView)nativeControl.ViewWithTag(1);

				cardView.Layer.CornerRadius = 2.0f;
				cardView.Layer.MasksToBounds = false;
				cardView.Layer.ShadowOffset = new CoreGraphics.CGSize(1.0f, 1.0f);
				cardView.Layer.ShadowOpacity = 0.5f;
				cardView.Layer.ShadowColor = UIColor.Gray.CGColor;
				cardView.Layer.ShadowRadius = 2.0f;

				imageView = (UIImageView)nativeControl.ViewWithTag(2);

				label = (UILabel)nativeControl.ViewWithTag(3);
				buttonView = (UIView)nativeControl.ViewWithTag(4);
				buttonShare = (UIButton)nativeControl.ViewWithTag(5);
				buttonExplore = (UIButton)nativeControl.ViewWithTag(6);

				ResizeNativeControl();

				SetNativeControl(nativeControl);
			}

			if (e.NewElement != null)
			{
				UpdateImage();
				UpdateText();
				UpdateOnClicked();
			}

			base.OnElementChanged(e);
		}

		protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
		{
			base.OnElementPropertyChanged(sender, e);

			if (e.PropertyName == CustomCardView.ImageUrlProperty.PropertyName)
			{
				UpdateImage();
			}
			if (e.PropertyName == CustomCardView.TextProperty.PropertyName)
			{
				UpdateText();
			}
		}

		private async void UpdateImage()
		{
			UIImage image = null;

			await Task.Run(() =>
			{
				var url = new NSUrl(Element.ImageUrl);
				var data = NSData.FromUrl(url);
				image = UIImage.LoadFromData(data);
			});

			imageView.Image = image;
		}

		private void UpdateText()
		{
			label.Text = Element.Text;
		}

		private void UpdateOnClicked()
		{
			onClick = Element.OnClicked;
			buttonShare.TouchDown += onClick;
			buttonExplore.TouchDown += onClick;
		}

		private void ResizeNativeControl()
		{
			var height = imageView.Frame.Height
			                      + label.Frame.Height		
			                      + buttonView.Frame.Height;
			
			nativeControl.Bounds = new CoreGraphics.CGRect(nativeControl.Bounds.X,
								nativeControl.Bounds.Y,
								UIScreen.MainScreen.Bounds.Size.Width,
								height);
		}

		protected override void Dispose(bool disposing)
		{
			if (Control != null)
			{
				buttonShare.TouchDown -= onClick;
				buttonExplore.TouchDown -= onClick;
			}

		  base.Dispose(disposing);
		}
	}
}

iOS の Custom Renderer です.

こちらも Android と同様に .xib から View を生成しています.

cardView.Layer.CornerRadius = 2.0f;
cardView.Layer.MasksToBounds = false;
cardView.Layer.ShadowOffset = new CoreGraphics.CGSize(1.0f, 1.0f);
cardView.Layer.ShadowOpacity = 0.5f;
cardView.Layer.ShadowColor = UIColor.Gray.CGColor;
cardView.Layer.ShadowRadius = 2.0f;

iOS には CardView がないので、View に影を付けることで CardView のような外観にしています.けっこう再現度高いんじゃないかと思います!

Image も Android と同様に async/await で取得しています.

SuperView のサイズを wrap_content のように自動調整したかったところですが、やり方が見つからなかったので明示的にリサイズを掛けています.

以上が iOS プロジェクトの実装です.



まとめ

CardView の Custom Renderer を Android, iOS で実装しました.

Android では一般的な CardView を Xamarin.Forms でも使用できるようになりました.


Custom Renderer を使い出すとネイティブの知識が否が応でも必要になってなかなかに辛いと思いました...

Custom Renderer の実装自体はそこまで重くないと思うので、ネイティブをゴリゴリ書けるならば積極的に活用すると良いと思います!


以上です.