ボイラーのチラシ裏

テクノロジーやフィギュア、ゲームに関すること

【MAUI】カルーセルコントロールを作ってみた

こんばんは。

今週のお題

今週はMAUIアプリでカルーセルコントロールを作ってみたので共有したいと思います。

まずはこちらの動画をどうぞ。

youtube.com

まるでKindleアプリのページめくりみたいじゃないですか?

ルーセル

ルーセルコントロールとは何でしょう?

ChatGPTに聞いてみました。

https://chat.openai.com/share/52830124-a93d-4209-8d09-896c4ae182ddchat.openai.com

ルーセルコントロールとは何でしょうか?>ChatGPT

水平方向に画像などを並べて、横方向にスクロールやスワイプして閲覧できるようにしたコントロールとのことなので、今回の「ページめくり」用途にはピッタリではないかと思うのです。

そのため、今回作ったコントロールはCarouselコントロールと名付けました。

ルーセルコントロールの実装

では次に、コントロールの実装をお見せします。

github.com

using Reactive.Bindings;
using Reactive.Bindings.Disposables;
using Reactive.Bindings.Extensions;
using System.Collections.ObjectModel;

namespace CarouselSample.Controls
{
    public class Carousel : AbsoluteLayout
    {
        private static readonly CompositeDisposable _disposables = new();
        private bool initGuard = true;

        public static readonly BindableProperty LeftObjectProperty = BindableProperty.Create(nameof(LeftObject), typeof(ImageSource), typeof(Carousel));

        public static readonly BindableProperty CenterObjectProperty = BindableProperty.Create(nameof(CenterObject), typeof(ImageSource), typeof(Carousel));

        public static readonly BindableProperty RightObjectProperty = BindableProperty.Create(nameof(RightObject), typeof(ImageSource), typeof(Carousel));

        public static readonly BindableProperty ImageSourcesProperty = BindableProperty.Create(nameof(ImageSources), typeof(ObservableCollection<ImageSource>), typeof(Carousel), propertyChanged: ImageSources_PropertyChanged);

        public static readonly BindableProperty IndexProperty = BindableProperty.Create(nameof(Index), typeof(int), typeof(Carousel), propertyChanged: Index_PropertyChanged);

        private static void Index_PropertyChanged(BindableObject bindable, object oldvalue, object newvalue)
        {
            var newIndex = (int)newvalue;
            if (bindable is Carousel { initGuard:true } c)
            {
                var collection = c.ImageSources;
                c.LeftControl.Source = newIndex - 1 >= 0 ? collection[newIndex - 1] : collection.Last();
                c.CenterControl.Source = collection[newIndex];
                c.RightControl.Source = newIndex + 1 > collection.Count - 1 ? collection.First() : collection[newIndex + 1];
                c.initGuard = false;
            }
        }

        private static void ImageSources_PropertyChanged(BindableObject bindable, object oldvalue, object newvalue)
        {
            var collection = newvalue as ObservableCollection<ImageSource>;
            if (bindable is Carousel c)
            {
                c.LeftControl.Source = c.Index - 1 >= 0 ? collection[c.Index - 1] : collection.Last();
                c.CenterControl.Source = collection[c.Index];
                c.RightControl.Source = c.Index + 1 > collection.Count - 1 ? collection.First() : collection[c.Index + 1];
            }
        }

        public ImageSource LeftObject
        {
            get { return (ImageSource)GetValue(LeftObjectProperty); }
            set { SetValue(LeftObjectProperty, value); }
        }

        public ImageSource CenterObject
        {
            get { return (ImageSource)GetValue(CenterObjectProperty); }
            set { SetValue(CenterObjectProperty, value); }
        }

        public ImageSource RightObject
        {
            get { return (ImageSource)GetValue(RightObjectProperty); }
            set { SetValue(RightObjectProperty, value); }
        }

        public ObservableCollection<ImageSource> ImageSources
        {
            get { return (ObservableCollection<ImageSource>)GetValue(ImageSourcesProperty); }
            set { SetValue(ImageSourcesProperty, value); }
        }

        public int Index
        {
            get { return (int)GetValue(IndexProperty); }
            set { SetValue(IndexProperty, value); }
        }

        public ReactiveCommandSlim LoadedCommand { get; } = new();

        public static readonly BindableProperty SwipeLeftCommandProperty = BindableProperty.Create(nameof(SwipeLeftCommand), typeof(ReactiveCommandSlim), typeof(Carousel));

        public static readonly BindableProperty SwipeRightCommandProperty = BindableProperty.Create(nameof(SwipeRightCommand), typeof(ReactiveCommandSlim), typeof(Carousel));

        public ReactiveCommandSlim SwipeLeftCommand
        {
            get { return (ReactiveCommandSlim)GetValue(SwipeLeftCommandProperty); }
            set { SetValue(SwipeLeftCommandProperty, value); }
        }

        public ReactiveCommandSlim SwipeRightCommand
        {
            get { return (ReactiveCommandSlim)GetValue(SwipeRightCommandProperty); }
            set { SetValue(SwipeRightCommandProperty, value); }
        }

        private ReactiveCommandSlim _SwipeLeftCommand { get; } = new();
        private ReactiveCommandSlim _SwipeRightCommand { get; } = new();

        public Image LeftControl { get; } = new();
        public Image CenterControl { get; } = new();
        public Image RightControl { get; } = new();

        public HorizontalStackLayout HorizontalStackLayout { get; } = new();

        private bool init = true;

        public Carousel()
        {
            SwipeLeftCommand = _SwipeLeftCommand = new();
            SwipeRightCommand = _SwipeRightCommand = new();
            
            HorizontalStackLayout.Children.Add(LeftControl);
            HorizontalStackLayout.Children.Add(CenterControl);
            HorizontalStackLayout.Children.Add(RightControl);
            this.Children.Add(HorizontalStackLayout);
            
            this.SizeChanged += (sender, args) =>
            {
                // Carouselのサイズに合わせて、コントロールのサイズを更新
                LeftControl.WidthRequest = this.Width;
                LeftControl.HeightRequest = this.Height;
                CenterControl.WidthRequest = this.Width;
                CenterControl.HeightRequest = this.Height;
                RightControl.WidthRequest = this.Width;
                RightControl.HeightRequest = this.Height;

                if (init)
                {
                    LeftControl.TranslationX = -LeftControl.Width;
                    CenterControl.TranslationX = 0;
                    RightControl.TranslationX = CenterControl.Width;

                    HorizontalStackLayout.TranslationX = -this.Width;
                    init = false;
                }
            };
            
            this.Loaded += (sender, args) =>
            {
                LeftControl.Aspect = Aspect.AspectFit;
                CenterControl.Aspect = Aspect.AspectFit;
                RightControl.Aspect = Aspect.AspectFit;

                initGuard = true;
            };
            this.Unloaded += (sender, args) =>
            {
                initGuard = true;
            };

            this.GestureRecognizers.Add(new SwipeGestureRecognizer() { Command = SwipeLeftCommand, Direction = SwipeDirection.Left });
            this.GestureRecognizers.Add(new SwipeGestureRecognizer() { Command = SwipeRightCommand, Direction = SwipeDirection.Right });

            //右から左にスワイプする
            _SwipeLeftCommand.Subscribe(async () =>
            {
                initGuard = false;

                RightControl.Opacity = 1;
                // アニメーションの完了を待機
                await HorizontalStackLayout.TranslateTo(-this.Width * 2, 0, 250, Easing.SinInOut);
                
                // Indexを更新
                Index = (Index + 1) % ImageSources.Count;
                
                // RightControlとCenterControlを更新
                LeftControl.Source = CenterControl.Source;
                
                CenterControl.Source = RightControl.Source;

                await Task.Delay(50);
                
                HorizontalStackLayout.TranslationX = -this.Width;
                
                // 新しいRightControlを設定
                RightControl.Source = ImageSources[(Index + 1) % ImageSources.Count];
                
            }).AddTo(_disposables);

            //左から右にスワイプする
            _SwipeRightCommand.Subscribe(async () =>
            {
                initGuard = false;

                LeftControl.Opacity = 1;
                // アニメーションの完了を待機
                await HorizontalStackLayout.TranslateTo(0, 0, 250, Easing.SinInOut);
                
                // Indexを更新
                Index = Index - 1 >= 0 ? Index - 1 : ImageSources.Count - 1;
                
                // LeftControlとCenterControlを更新
                RightControl.Source = CenterControl.Source;
                
                CenterControl.Source = LeftControl.Source;

                await Task.Delay(50);
                
                HorizontalStackLayout.TranslationX = -this.Width;

                // 新しいLeftControlを設定
                LeftControl.Source = ImageSources[Index - 1 >= 0 ? Index - 1 : ImageSources.Count - 1];

            }).AddTo(_disposables);
        }
    }
}

特徴(ChatGPTに解説してもらいました)

  1. リアクティブプログラミングの採用: Reactive.Bindings ライブラリを利用しており、MVVMパターンにおけるリアクティブプログラミングを実現しています。これにより、UIコントロールの状態やイベントの反応を、より宣言的に記述できます。

  2. バインダブルプロパティの定義: BindableProperty を使用しているため、XAMLや他のコードからバインド可能なプロパティ(例:LeftObject, CenterObject, RightObject, ImageSources, Index)を提供しています。これにより、外部からこれらのプロパティの変更を監視したり、逆にこれらのプロパティに外部から値を設定したりできます。

  3. ルーセルの動的な更新: ImageSources_PropertyChanged と Index_PropertyChanged メソッドを通じて、画像リストやインデックスが変更された際に自動的にカルーセルの表示を更新します。これは、ユーザーが画像をスワイプするたびに、次または前の画像が適切に表示されることを保証します。

  4. スワイプジェスチャーのサポート: 左へのスワイプと右へのスワイプを検出するために、SwipeGestureRecognizer を使用しています。これにより、ユーザーの操作に応じてカルーセル内の画像を動的に切り替えることができます。

  5. アニメーションの使用: 画像が切り替わる際にスムーズなアニメーション効果を提供します。これは、TranslateTo メソッドを使用して実現されており、カルーセルのユーザー体験を向上させています。

  6. リソース管理: CompositeDisposable を使用して、イベント購読などのリソースを管理し、カルーセルコントロールが不要になった時にこれらのリソースを適切に解放します。

簡単な解説

このCarouselコントロールは、画像を左、中央、右の3つの位置に表示し、ユーザーが左または右にスワイプすることで画像を切り替えることができます。

ビューポートは中央の画像の範囲のみで、左右にスワイプした時に中央の画像が左右の画像に押し出される形でアニメーションします。

アニメーションした後、右から左にスワイプした場合と左から右にスワイプした場合で処理がわかれます。

右から左にスワイプした場合

  1. 右の画像コントロールの透明度を100%にします。
  2. 3枚の画像全体を左に移動(アニメーション)します。
  3. インデックスをインクリメントします。
  4. 左の画像コントロールのソースに中央の画像コントロールのソースを代入します。
  5. 中央の画像コントロールのソースに右の画像コントロールのソースを代入します。
  6. 非同期で50ms待ちます
  7. 右の画像コントロールに、現在のインデックスを基準に+1の位置のソースを代入します。

左から右にスワイプした場合

  1. 左の画像コントロールの透明度を100%にします。
  2. 3枚の画像全体を右に移動(アニメーション)します。
  3. インデックスをデクリメントします。
  4. 右の画像コントロールのソースに中央の画像コントロールのソースを代入します。
  5. 中央の画像コントロールのソースに左の画像コントロールのソースを代入します。
  6. 非同期で50ms待ちます
  7. 左の画像コントロールに、現在のインデックスを基準に-1の位置のソースを代入します。

という感じでそれぞれReactiveCommandを仕掛けておき、GestureRecognizersに2つの左右にスワイプする時のコマンドを設定したSwipeGestureRecognizerインスタンスを追加します。 キモは非同期で50ms待つところですかね。待たないとちらつきが発生してしまいます。

次回予告

最近、C#でAIを作るためのライブラリ(GPU対応)を自分でも珍しく熱心に作っているので、その話題を記事にしようかな?

たぶん、複数記事に分かれると思います。作った(作っている)ライブラリが複数なので。長期に記事を書けたらいいな。