shuhelohelo’s blog

Xamarin.Forms多めです.

Xamarin.Formsで上下スクロールにあわせてナビゲーションバーを表示・非表示 Reactive Extensionsで

Xamarin.Formsで上下スクロールに合わせてナビゲーションバーを表示,非表示させる方法について書きました.

shuhelohelo.hatenablog.com

この記事ではフラグやスクロールの値を保持して,それらを使ってIf文で処理を分けて作りました.

グローバル変数やIf文が多用され,非常に見にくく,分かりづらいコードになっています.

これは恥ずかしいと思い,スマートに書き直したいなと思いました.

そこでReactive Extensions(Rx)です.

Rxは連続的なイベントの流れを扱うことに向いているということを以前に聞き,その当時はマウスのドラッグ・アンド・ドロップをRxを使って記述すると,とても簡潔に書くことができて,とても感動した記憶がありました.

今回も連続するスクロールイベントを扱うので,Rxが向いている気がしました.

System.Reactive.Linqをインストールする

Nugetでインストールします.

f:id:shuhelohelo:20200620035342p:plain

Fade In/Outの場合

はじめ,以下のようなコードを書きました.

        //表示,非表示が切り替わる位置
        private readonly double _headerToggleYPosition = 100;
        //アニメーションにかける時間.
        private readonly uint _animateLength = 200;
        //ナビゲーションバーの表示状態を保持
        private bool _isHeaderVisible = false;
        private async Task ToggleHeaderVisibility(double scrollYPosition)
        {
            bool prevIsHeaderVisible = _isHeaderVisible;
            if (scrollYPosition >= _headerToggleYPosition)
            {
                //指定位置までスクロールアップしたら,ナビゲーションバーを表示するフラグOn
                _isHeaderVisible = true;
            }
            else
            {
                //スクロールダウンでフラグOff
                _isHeaderVisible = false;
            }

            //指定位置をまたがない場合,アニメーションの必要は無いのでここまで.
            if (_isHeaderVisible == prevIsHeaderVisible)
            {
                return;
            }

            //ナビゲーションバーの表示・非表示のアニメーション
            if (_isHeaderVisible)
            {
                await frame_Header.FadeTo(1.0, _animateLength);
            }
            else
            {
                await frame_Header.FadeTo(0, _animateLength);
            }
        }

Rxを使うと以下のように書けます.

        private readonly double _headerToggleYPosition = 100;
        private readonly uint _animateLength = 200;

        public FadeHeader2()
        {
            InitializeComponent();
            this.BindingContext = this;

            Message = scrollView.ScrollY.ToString();
            frame_Header.Opacity = 0;

            var fadeIn = Observable.FromEventPattern<ScrolledEventArgs>(scrollView, nameof(scrollView.Scrolled));
            fadeIn.Where(x => x.EventArgs.ScrollY >= _headerToggleYPosition)
                .Subscribe(x =>
                {
                    frame_Header.FadeTo(1.0, _animateLength);
                    Message = x.EventArgs.ScrollY.ToString();
                });

            var fadeOut = Observable.FromEventPattern<ScrolledEventArgs>(scrollView, nameof(scrollView.Scrolled));
            fadeOut.Where(x => x.EventArgs.ScrollY < _headerToggleYPosition)
                .Subscribe(x =>
                {
                    frame_Header.FadeTo(0.0, _animateLength);
                    Message = x.EventArgs.ScrollY.ToString();
                });
        }

Fade Inの処理とFade Outの処理を別々にRxで書くことで,If文やフラグを使う必要がなく,また,それぞれの条件を簡潔かつわかりやすく書くことができました.

Slide In/Outの場合

Rx云々の前にそもそもひどい書き方,というのは置いておいて,とにかく以下のように書きました.

        private readonly double _slideToggleYPosition_Up = 110;
        private double _slideToggleYPosition_Down = 0;

        private double _prevScrollYPosition = 0;
        private ScrollDirection _prevDirection = ScrollDirection.Down;

        private void SlideHeaderProcess(double scrollYPosition)
        {
            ScrollDirection scrlDirection = (scrollYPosition - _prevScrollYPosition) >= 0 ? ScrollDirection.Up : ScrollDirection.Down;

            //スクロールの方向が下方向に変わった
            if (scrlDirection != _prevDirection && scrlDirection == ScrollDirection.Down)
            {
                _slideToggleYPosition_Down = scrollYPosition;
            }

            //上方向にスクロールしている時
            if (scrlDirection == ScrollDirection.Up)
            {
                //上方向にスクロールするときは決まった位置から
                //ナビゲーションバーを隠す処理を開始
                if (scrollYPosition >= _slideToggleYPosition_Up)
                {
                    //前回のスクロール位置との差を計算する
                    double deltaY = Math.Abs(scrollYPosition - _prevScrollYPosition);
                    //次のTranslationYの値を計算する
                    double nextTransY = frame_Header.TranslationY - deltaY;

                    //次のTranslationYの値とナビゲーションバーのコントロールの高さを比較する.
                    //TranslationYの変化はナビゲーションバーの高さと開始位置を足したものちょうど
                    if (nextTransY < -(frame_Header.Height + frame_Header.Y))
                    {
                        nextTransY = -(frame_Header.Height + frame_Header.Y);
                    }

                    frame_Header.TranslationY = nextTransY;
                    Message = frame_Header.TranslationY.ToString();
                }
            }

            //スクロールの方向が引き続き下方向
            if (scrlDirection == ScrollDirection.Down)
            {
                double deltaY = Math.Abs(scrollYPosition - _prevScrollYPosition);
                double nextTransY = frame_Header.TranslationY + deltaY;

                if (nextTransY > 0)
                {
                    nextTransY = 0;
                }

                frame_Header.TranslationY = nextTransY;

                Message = frame_Header.TranslationY.ToString();
            }

            //スクロール方向を記録
            _prevDirection = scrlDirection;
            //スクロール位置を記録
            _prevScrollYPosition = scrollYPosition;
        }
    }

    enum ScrollDirection
    {
        Up,
        Down,
    }

これをRxを使って以下のように書きました.

        private void SetHeaderBehaviorByScroll()
        {
            //ヘッダーを隠す側の動作
            var hide = Observable.FromEventPattern<ScrolledEventArgs>(scrollView, nameof(scrollView.Scrolled));
            hide.Zip(hide.Skip(1), (prev, current) => CreateScrollInfo(current.EventArgs.ScrollY, prev.EventArgs.ScrollY))
                .Where(x => x.Direction == ScrollDirection.Up && x.CurrentY >= _slideToggleYPosition)
                .Repeat()
                .Subscribe(x =>
                {
                    double nextTransY = frame_Header.TranslationY - x.DeltaY;
                    if (nextTransY < -(frame_Header.Height + frame_Header.Y))
                    {
                        nextTransY = -(frame_Header.Height + frame_Header.Y);
                    }
                    frame_Header.TranslationY = nextTransY;

                    Message = $"{frame_Header.TranslationY} {x.Direction}";
                });

            //ヘッダーを表示する側の動作
            var show = Observable.FromEventPattern<ScrolledEventArgs>(scrollView, nameof(scrollView.Scrolled));
            show.Zip(show.Skip(1), (prev, current) => CreateScrollInfo(current.EventArgs.ScrollY, prev.EventArgs.ScrollY))
                .Where(x => x.Direction == ScrollDirection.Down)
                .Repeat()
                .Subscribe(x =>
                {
                    double nextTransY = frame_Header.TranslationY - x.DeltaY;
                    if (nextTransY > 0)
                    {
                        nextTransY = 0;
                    }
                    frame_Header.TranslationY = nextTransY;

                    Message = $"{frame_Header.TranslationY} {x.Direction}";
                });
        }

        private ScrollInfo CreateScrollInfo(double currentScrollY, double prevScrollY) =>
            new ScrollInfo
            {
                CurrentY = currentScrollY,
                DeltaY = currentScrollY - prevScrollY,
                Direction = (currentScrollY - prevScrollY) >= 0 ? ScrollDirection.Up : ScrollDirection.Down
            };


        internal class ScrollInfo
        {
            public double CurrentY { get; set; }
            public double DeltaY { get; set; }
            public ScrollDirection Direction { get; set; }
        }

        internal enum ScrollDirection
        {
            Up,
            Down
        }

Slide In,Slide Outの処理を別々に書くことができ,フラグなどのグローバルな変数も使わずに住むため,見通しがよくなりました.

画面上部に張り付くヘッダー

これまでは上スクロールで画面外に隠れるけれど,下スクロールすると出てくるようなヘッダーでした. 次は,スクロールと一緒に動くけれど,画面上部まで行ったらそこに張り付いて移動しないヘッダーです.

こちらのおしゃれなサンプルを大いに参考にしました.

github.com

このヘッダーの部分のみを作成したページを追加しました.

ヘッダー部分は参考にしたコードとまったく同じです.

ScrollViewの中にAbsoluteLayoutを置いてその中にFrameでヘッダーを配置します.位置はAbsoluteLayout内の絶対位置で指定しておきます.

これでスクロールと一緒にヘッダーが動きます.

ヘッダーが画面上部で張り付く動作は,Scrolledイベントを使ってコードビハインドで以下のようにします.

        private void OnRootScrollViewScrolled(object sender, ScrolledEventArgs e)
        {
            //Sticky Header
            var position = EmptyLayout.Height + Math.Max(0, RootScrollView.ScrollY - EmptyLayout.Height);
            AbsoluteLayout.SetLayoutBounds(TabsLayout, new Rectangle(0, position, 1, TabsLayout.Height));
        }

ヘッダーのY位置が0未満にならないようにしています.ありがたいです.

ここまでは人様のコードそのままなのですが,唯一自分で工夫した点があります.

ScrollViewの中でListViewを使うと,ListViewもスクロールするためScrollViewと合わせてスクロールが入れ子になってしまうことを,ListViewを使わずにStackLayoutで解消した点です.

しかしStackLayoutを使うと今度は項目を選択したときの背景色が変化しないため,これをVisualStateManagerを使って,選択された項目の背景色が変化するようにしています.

    <ContentPage.Resources>
        <ResourceDictionary>
            <Style x:Key="ItemTemplateStyle" TargetType="StackLayout">
                <Setter Property="VisualStateManager.VisualStateGroups">
                    <VisualStateGroupList>
                        <VisualStateGroup>
                            <VisualState x:Name="Selected">
                                <VisualState.Setters>
                                    <Setter Property="BackgroundColor" Value="Pink" />
                                </VisualState.Setters>
                            </VisualState>
                            <VisualState x:Name="Unselected" />
                        </VisualStateGroup>
                    </VisualStateGroupList>
                </Setter>
            </Style>
        </ResourceDictionary>
    </ContentPage.Resources>
                <StackLayout
                    AbsoluteLayout.LayoutBounds="0,400,1,AutoSize"
                    AbsoluteLayout.LayoutFlags="WidthProportional"
                    BackgroundColor="White">
                    <BindableLayout.ItemsSource>
                        <x:Array Type="{x:Type x:String}">
                            <x:String>a</x:String>
                            <x:String>b</x:String>
                            <x:String>c</x:String>
                            <x:String>d</x:String>
                            <x:String>e</x:String>
                            <x:String>f</x:String>
                        </x:Array>
                    </BindableLayout.ItemsSource>
                    <BindableLayout.ItemTemplate>
                        <DataTemplate>
                            <StackLayout Style="{StaticResource ItemTemplateStyle}">
                                <StackLayout.GestureRecognizers>
                                    <TapGestureRecognizer Tapped="TapGestureRecognizer_Tapped" />
                                </StackLayout.GestureRecognizers>
                                <Label InputTransparent="True" Text="{Binding .}" />
                                <Image InputTransparent="True" Source="avatar_men_1" />
                            </StackLayout>
                        </DataTemplate>
                    </BindableLayout.ItemTemplate>
                </StackLayout>

f:id:shuhelohelo:20200325035102g:plain

ソースコード

github.com