Xamarin.Forms で動きのある View を作る

本エントリーは、Xamarin その1 Advent Calendar 2017 の6日目です.
昨日は @muak_x 先生の Xamarin.FormsのListViewやTableViewで使えるCustomCell(NativeCell)の作り方のまとめ でした.

 

今回は Xamarin.Forms で動きのある View を作る Tips を書いてみます.
App Service や .NET Standard の記事の中で若干地味な感が否めませんが、どうぞお付き合いください.

 

スマートフォンアプリの UI において、アニメーションは重要な要素の1つです.

Material Design に Motion のガイドラインが規定されています.

iOS の Human Interface Guidelines にも Animation の項目があります.

 

Xamarin.Forms にも Animation はちゃんと用意されています.

日本語記事はこの辺が参考になりそうです.


 

今回は以前見て感動した View をクリスマス風に再現したいと思います.

英語を自由に読める方は原文を読んで頂いたほうが分かりやすいかもしれません.

今回実装した View はこんな感じです.

どうでしょう、アニメーションかっこよくないですか!?

全体構成

まずは、View を構成する要素をご説明します.
今回のアコーディオン表現は以下の要素で構成されています.

  • ISelectable.cs (インターフェース)
  • ItemsView.cs (View の基底クラス)
  • Accordion.cs (View の実装クラス)
  • BriefView.xaml (一覧表示用の View 定義)
  • DetailView.xaml (アコーディオン時に表示される View 定義)

コアとなるのは ISelectable, ItemsView, Accordion の3つです.
2つの .xaml は任意のものを指定できます.

先に少しお見せしてしまうと以下のように DataTemplate で指定します.

以上が全体構成でした.
次からはそれぞれの要素を見ていきましょう.

ISelectable.cs (インターフェース)

現在、アコーディオン表示をされているか否かの状態を保持するインターフェースになります.

namespace CoolViewSample.Models
{
    public interface ISelectable
    {
        bool IsSelected { get; set; }
    }
}

要素をタップし、アコーディオン表示されたら IsSelected = ture;
再度タップし、アコーディオン表示が解除されたら IsSelected = false;

上記のように状態を管理します.

ISelectable に関しては以上です.

ItemsView.cs (View の基底クラス)

まずはソースコード全文を記します.

 

1点だけ補足として View 生成の部分にフォーカスします.

特筆しておきたいのは AddGesture の中で View の子要素の分だけ 再起的に AddGesture を呼び出している点でしょうか.
Xamarin.Forms の TapGestureRecognizer は少し癖があるので、こうして回避しているのではないかと思います.

 

以上が基底クラスである ItemsView の実装でした.

Accordion.cs (View の実装クラス)

先程までは基底クラスの実装でした.
次は基底クラスを拡張した Accordion クラスの実装になります.
まずは全ソースコードを記します.

using CoolViewSample.Models;
using System.Linq;
using Xamarin.Forms;

namespace CoolViewSample.Views
{
    public class Accordion : ItemsView
    {
        private View _detailView;
        private ScrolledEventArgs _scrolledEventArgs;
        private double _lastScrollPosition;
        private readonly ContentView _overlay;

        public Accordion()
        {
            ScrollView.Scrolled += AccordionViewScrolled;

            _overlay = new ContentView
            {
                IsVisible = false
            };

            AddGesture(_overlay, gesture: new TapGestureRecognizer(view =>
            {
                _overlay.IsVisible = false;
                SelectedCommand.Execute(SelectedItem);
            }));

            Children.Add(_overlay);
        }

        private void AccordionViewScrolled(object sender, ScrolledEventArgs e)
        {
            _scrolledEventArgs = e;
        }

        protected override void SetSelected(ISelectable selectable)
        {
            selectable.IsSelected = !selectable.IsSelected;
        }

        public static readonly BindableProperty ItemDetailTemplateProperty =
            BindableProperty.Create(p => p.ItemDetailTemplate, default(DataTemplate)
            , BindingMode.TwoWay, null, ItemDetailTemplateChanged);

        public DataTemplate ItemDetailTemplate
        {
            get { return (DataTemplate)GetValue(ItemDetailTemplateProperty); }
            set { SetValue(ItemDetailTemplateProperty, value); }
        }

        private static void ItemDetailTemplateChanged(BindableObject bindable, DataTemplate oldvalue, DataTemplate newvalue)
        {
            var accordionView = bindable as Accordion;
            if (accordionView == null) return;
            accordionView.CreateDetailView();
        }

        private void CreateDetailView()
        {
            var itemDetail = ItemDetailTemplate.CreateContent() as View;

            _detailView = new ScrollView
            {
                Content = itemDetail
            };
        }

        protected override void SetSelectedItem(ISelectable selectedItem)
        {
            base.SetSelectedItem(selectedItem);

            var element = ItemsStackLayout.Children.FirstOrDefault(x => x.BindingContext == selectedItem);
            if (element == null) return;

            var index = ItemsStackLayout.Children.IndexOf(element);
            var scrollPosition = _scrolledEventArgs != null ? _scrolledEventArgs.ScrollX : 0;

            if (selectedItem.IsSelected)
            {
                var scrollDistance = element.X - scrollPosition; // the distance to scroll
                _lastScrollPosition = scrollPosition;

                if (Device.OS != TargetPlatform.WinPhone)
                {
                    if (ItemsStackLayout.Children.Contains(_detailView))
                        ItemsStackLayout.Children.Remove(_detailView);
                }
                else
                    CreateDetailView();

                _detailView.BindingContext = selectedItem;

                if (scrollDistance < Width / 2)
                    ItemsStackLayout.Children.Insert(index + 1, _detailView);
                else
                    ItemsStackLayout.Children.Insert(index, _detailView);

                var width = Width - element.Width; // width to expand to

                _overlay.IsVisible = true;

                _detailView.Animate("expand",
                    percent =>
                    {
                        var change = width * percent;
                        _detailView.WidthRequest = change;

                        var position = scrollPosition + (scrollDistance * percent);
                        ScrollView.ScrollToAsync(position, 0, false);

                    }, 0, 400, Easing.Linear, (d, b) =>
                    {
                        _overlay.IsVisible = true;
                    });
            }
            else
            {
                var width = _detailView.WidthRequest; // width to collapse
                var scrollDistance = scrollPosition - _lastScrollPosition; // the distance to scroll

                _detailView.Animate("collapse",
                    percentage =>
                    {
                        var change = width * percentage;
                        _detailView.WidthRequest = width - change;

                        var position = scrollPosition - (scrollDistance * percentage);
                        ScrollView.ScrollToAsync(position, 0, false);

                    }, 0, 400, Easing.Linear, (d, b) =>
                    {
                        ItemsStackLayout.Children.Remove(_detailView);
                        _detailView.Parent = null;
                        _overlay.IsVisible = false;
                    });
            }
        }
    }
}

 

今回のメイントピックである Animation 周りを説明します.
以下、Animation の該当部分です.

 

まず、前準備として選択された Item のインデックスと現在のスクロール位置を取得します.

 

Animation の処理として、アコーディオンを開く処理と閉じる処理があるので、
まずは開くほうを説明します.
開くほうの処理の全容は下記のようになっています.

 

まずは選択された Item が画面座標系(実際にディスプレイ上に表示されている座標系)でどの位置にいるのかを調べます.
ViewElement.X は”親 View からの相対位置”を表すプロパティであり、画面座標系ではありません
ですので、まずはこの相対位置を画面座標系の位置に直します.

var scrollDistance = element.X - scrollPosition; // the distance to scroll
_lastScrollPosition = scrollPosition;

 

座標系のイメージはこのような感じです.
ViewElement.X – ScrollEventArgs.ScrollX = 画面座標系のX座標

 

座標が決まったらアコーディオン表示したい View を StackLayout の中に挿入します.

画面座標系のX座標が Accodion の中心位置(Width / 2)よりも小さかった場合、
選択した要素の右側(index + 1)に _detailView を挿入します.
反対に、X座標が Accodion の中心位置よりも大きかった場合、
選択した要素の左側(index)に _detailView を挿入します.

 

最後に Animation の部分です.

width(アコーディオン表示する View の幅) = Width(Accodion の幅) - element.Width(要素の幅) で、width を取得します.

Animate の引数は以下のようになっています.

  • self: The object on which this method will be run.
  • name: An animation key that should be unique among its sibling and parent animations for the duration of the animation.
  • callback: An action that is called with successive animation values.
  • rate: The time, in milliseconds, between frames.
  • length: The number of milliseconds over which to interpolate the animation.
  • easing: The easing function to use to transision in, out, or in and out of the animation.
  • finished: An action to call when the animation is finished.
  • repeat: A function that returns true if the animation should continue.

Action callback の部分では percent で [1, 0] の値が取れるので、それを元に change と scrollPosition を計算しています.
ここでミソなのは、アコーディオン表示と同時に ScrollView をスクロールさせることです.
これにより Item のX座標を画面座標系で0に合わせることができます.

 

アコーディオンを閉じる方に移ります.
閉じる方の処理は以下のようになっています.

開くときとほぼ逆順のイメージです.
Animation の finish には _detailView と _overlay(実質 TapGesture)を取り除く処理を書いています.

 

以上が Animation の全容でした.

まとめ

今回は Xamarin.Forms の Animation について書きました.

考慮しなくてはいけない点は主に、

  • 座標系
  • TapGesture
  • Animationメソッド

だと思います.

特に座標系は難しいですね.
でも、Animation ある View はやはりかっこいいですね!!

 

明日は @kazuyukimiyake さんの Azure の何かと連携する系のネタです.楽しみですね!!

それではみなさん来年も楽しい Xamarin Life を!!