kamulog

xamarin.formsのネタなど

Xamarin.Forms で ListView のスクロールに合わせて NavigationBar を隠したり出したりするやつを実装する

この記事は Xamarin Advent Calendar 2018 の1日目の記事です。

ListView のスクロールに合わせて NavigationBar が隠れたり出てきたりする、今ではいろんなアプリで実装されてるあれです。最近では twitter 公式にも実装されました。 Xamarin.Forms にはそんな機能は存在しないので今回 Forms での実装に挑戦してみました。

リポジトリ

記事中のコードは前後が省略されていますので、詳細を確認したい場合はこちらを参照してください。

github.com

実装方法調査

調査した結果、 iOS では UINavigationController.HidesBarsOnSwipe というのを使い、 Android では CoordinatorLayoutAppBarLayout を使えば良いようでした。

NETStandard プロジェクトの実装

とりあえず共通部品が必要なので、以下のような NavigationPage のサブクラスを定義し、連動スクロールのOnOff用の添付プロパティを実装します。 なぜ普通の Bindable じゃなくて添付プロパティなのかというと、実際に使うのは NavigationPage ではなく ContentPage からだからです。

public class CoordinatorPage:NavigationPage
{
    public static readonly BindableProperty IsBarScrollProperty =
        BindableProperty.CreateAttached(
                "IsBarScroll",
                typeof(bool),
                typeof(CoordinatorPage),
                default(bool)
            );

    public static void SetIsBarScroll(BindableObject view, bool value)
    {
        view.SetValue(IsBarScrollProperty, value);
    }

    public static bool GetIsBarScroll(BindableObject view)
    {
        return (bool)view.GetValue(IsBarScrollProperty);
    }
}

次にこれを使うページを作成します。

<?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:rdr="clr-namespace:Sample.Renderers;assembly=Sample"
    xmlns:prism="clr-namespace:Prism.Mvvm;assembly=Prism.Forms" 
    prism:ViewModelLocator.AutowireViewModel="True" 
    x:Class="Sample.Views.MainPage" Title="MainPage"
    rdr:CoordinatorPage.IsBarScroll="true"  >
    
    <ListView ItemsSource="{Binding ItemsSource}">
        <ListView.ItemTemplate>
            <DataTemplate>
                <TextCell Text="{Binding}" />
            </DataTemplate>
        </ListView.ItemTemplate>
    </ListView>
</ContentPage>

ViewModelのコードは省略します。

App.xaml.cs の例はこんな感じです。(prismを使用しています)

public partial class App : PrismApplication
{
    public App(IPlatformInitializer initializer = null) : base(initializer) { }


    protected override void OnInitialized()
    {
        InitializeComponent();

        NavigationService.NavigateAsync("CoordinatorPage/MainPage");
    }

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {
        containerRegistry.RegisterForNavigation<MainPage>();
        containerRegistry.RegisterForNavigation<CoordinatorPage>();
    }
}

iOS の実装

public class CoordinatorPageRenderer:NavigationRenderer
{
    CoordinatorPage _coordinatorPage => Element as CoordinatorPage;

    protected override void OnElementChanged(VisualElementChangedEventArgs e)
    {
        base.OnElementChanged(e);

        if(e.OldElement != null)
        {
            _coordinatorPage.Popped -= OnPopped;
        }

        if(e.NewElement != null)
        {            
            _coordinatorPage.Popped += OnPopped;
            UpdateIsBarScroll();
        }
    }

    protected override void Dispose(bool disposing)
    {
        if(disposing)
        {
            _coordinatorPage.Popped -= OnPopped;
        }
        base.Dispose(disposing);
    }

    void OnPopped(object sender, NavigationEventArgs e)
    {
        UpdateIsBarScroll();
    }

    public override void PushViewController(UIViewController viewController, bool animated)
    {
        base.PushViewController(viewController, animated);

        UpdateIsBarScroll();
    }

    void UpdateIsBarScroll()
    {
        HidesBarsOnSwipe = CoordinatorPage.GetIsBarScroll(_coordinatorPage.CurrentPage);
    }
}

HidesBarsOnSwipe = CoordinatorPage.GetIsBarScroll(_coordinatorPage.CurrentPage); の部分が全てです。 他の部分は、どのタイミングで HidesBarsOnSwipe を有効にするかという処理や、初期化処理、後始末処理です。 PushViewController はページが Push されたタイミングで呼ばれ、Popped イベントはページが Pop されたタイミングで呼ばれるので、これらタイミングで Page単位での有効無効の処理を行います。

これで実行すれば ListView のスクロールに合わせて NavigationBar が同期してスクロールします。

f:id:kamusoft:20181120154319g:plain

Android の実装

iOS のように CustomRenderer 一発 というようにはいきませんでした。 というのも Android で CoordinatorLayout と AppBarLayout を使った実装では以下のようなレイアウトをどこかに突っ込む必要があるからです。

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">
    
    <android.support.design.widget.AppBarLayout
        android:id="@+id/coordinatorAppBar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        
    </android.support.design.widget.AppBarLayout>
    
    <FrameLayout 
        android:id="@+id/coordinatorContent"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
    />
    
</android.support.design.widget.CoordinatorLayout>

AppBarLayout 内に Toolbar を配置する必要がありますが、Toolbar は動的に配置するのでここでは何も置きません。 また FrameLayout にスクロールするページ要素を配置しますが、これもコードで行うのでここでは空のままにします。 このレイアウトは Android プロジェクトの Resources/layout に CoordinatorPageLayout.axml として 保存しておきます。

どこにレイアウトを挿入するか

最初は NavigationPageRenderer に inflate して Fragment の対象を上の coorinatorContent 部分に差し替えようと思ったんですが、Xamarin.Forms.Platform.Android のソースを見てみると

public NavigationPageRenderer(Context context) : base(context)
{
    AutoPackage = false;
    Id = Platform.GenerateViewId();
    Device.Info.PropertyChanged += DeviceInfoPropertyChanged;
}
...
Task<bool> SwitchContentAsync(Page page, bool animated, bool removed = false, bool popToRoot = false)
{
    ...
    transaction.AddEx(Id, fragment);
    _fragmentStack.Add(fragment);    
    ...
}

こんな感じで自身の Id が直接参照されているのでいじりようがなく断念しました。Id を変数にいれてくれてさえいれば、NavigationPageRenderer のサブクラスだけで解決できたんですが残念です。 諦めてソースを辿っていくと FormsAppCompatActivity の OnCreate をオーバーライドするのが良さそうでした。

protected override void OnCreate(Bundle savedInstanceState)
{
    ...
    _layout = new ARelativeLayout(BaseContext);
    SetContentView(_layout);
    ...
}

この ContentView に CoordinatorLayout をセットして、元の _layout をそのサブビューに追加したら影響を最小限にできそうです。 というわけで以下のような FormsAppCompatActivity のサブクラスを作成します。

public class FormsCoordinatorActivity:FormsAppCompatActivity
{
    protected override void OnCreate(Bundle savedInstanceState)
    {
        base.OnCreate(savedInstanceState);

        var fieldInfo = typeof(FormsAppCompatActivity).GetField("_layout", BindingFlags.Instance | BindingFlags.NonPublic);
        var root = (RelativeLayout)fieldInfo.GetValue(this);

        root.RemoveFromParent();

        var newRoot = (ViewGroup)LayoutInflater.Inflate(Resource.Layout.CoordinatorPageLayout, null, false);

        var container = newRoot.FindViewById<Android.Widget.FrameLayout>(Resource.Id.coordinatorContent);

        using (var param = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MatchParent, ViewGroup.LayoutParams.MatchParent))
        {
            container.AddView(root, 0, param);
        }

        SetContentView(newRoot);
    }
}

セット済みの ContentView の取り方が分からなかったので Refrection で強引に親クラスの _layout フィールドを参照しています。 Inflate しているのは上の レイアウトxml です。元々の Android の View の階層は

  • _layout (RelativeLayout)
    • Platform
      • 各Page

という感じなんですが、これにより

  • CoordinatorLayout
    • _layout (RelativeLayout)
      • Platform
        • 各Page

という階層になります。 これでようやく下地ができました。

最後に、MainActivity.cs の継承元をこれに差し替えておきます。

public class MainActivity : FormsCoordinatorActivity
{
    ...
}

次は NavigationPageRenderer に手を入れて行きます。 NavigationPageRenderer で行なっていることは

  1. OnElementChanged で Toolbar を自身の子として追加
  2. OnLayout で Toolbar 領域を除いた部分に子 Page を描画
  3. 以降 Push Pop では子 Page は Fragment で差し替える

ざっくり言うとこんな感じです。

連動スクロールさせるには、Toolbar は CoordinatorLayout 内の AppBarLayout の子ビューとして追加しないといけないのでサブクラスで以下の手順で Toolbar を AppBarLayout に移動させて、Pageの描画領域を修正します。

  1. OnElementChanged で Toolbar を自身から取り除いて、AppBarLayoutに挿入する
  2. OnLayout で Page 領域を全域に戻す

以下、これの実装です。

public class CoordinatorPageRenderer:NavigationPageRenderer
{
    Toolbar _toolbar;
    AppBarLayout _appBarLayout;
    IPageController PageController => Element;
    int _barHeight;

    public CoordinatorPageRenderer(Context context) : base(context) { }

    protected override void OnElementChanged(ElementChangedEventArgs<NavigationPage> e)
    {
        base.OnElementChanged(e);

        if (e.OldElement != null)
        {
            if (_toolbar != null)
            {
                _toolbar.RemoveFromParent();
            }
        }

        if (e.NewElement != null)
        {
            _toolbar = (Toolbar)GetChildAt(0);

            var getNavBarHeight = typeof(NavigationPageRenderer).GetMethod("ActionBarHeight", BindingFlags.Instance | BindingFlags.NonPublic);
            _barHeight = (int)getNavBarHeight.Invoke(this, new object[] { });

            _appBarLayout = (Context as FormsAppCompatActivity).FindViewById<AppBarLayout>(Resource.Id.coordinatorAppBar);

            _toolbar.RemoveFromParent();

            using (var toolbarParams = new AppBarLayout.LayoutParams(LayoutParams.MatchParent, _barHeight))
            {
                toolbarParams.ScrollFlags = AppBarLayout.LayoutParams.ScrollFlagScroll | AppBarLayout.LayoutParams.ScrollFlagEnterAlways;
                _appBarLayout.AddView(_toolbar, toolbarParams);
            }
        }
    }

    protected override void OnLayout(bool changed, int l, int t, int r, int b)
    {
        base.OnLayout(changed, l, t, r, b);

        int marginTop = 0;
        if (!CoordinatorPage.GetIsBarScroll(Element.CurrentPage))
        {
            marginTop = _barHeight;
        }

        PageController.ContainerArea = new Rectangle(0, 0, Context.FromPixels(r - l), Context.FromPixels(b - t - marginTop));

        for (var i = 0; i < ChildCount; i++)
        {
            if (GetChildAt(i) is ViewGroup viewGroup)
            {
                if (viewGroup.ChildCount == 1 && viewGroup.GetChildAt(0) is PageRenderer)
                {
                    viewGroup.Layout(0, 0, r - l, b - t - marginTop);
                }
            }
        }
    }
}

まず、OnElementChanged で _toolbar = (Toolbar)GetChildAt(0); で toolbar を取得します。この取り方は XF.3.3.0 時点では有効ですが、今後は使えないかもしれないので注意してください。同一プロジェクトでの利用のみであれば FindViewById で取得するのが確実かもしれません。

_barHeight のくだりは Toolbar の高さを取得しています。NavigationPageRenderer 内部でいろいろやっているので、それをそのまま実行させています。これも上と同じく現時点で有効な方法です。というかここは別にサイズ決め打ちでも問題ないと思います。あるいは ソース の ActionBarHeight 部分をコピペしても良いと思います。

それから AppBarLayout を用意した Layout 内で割り当てた ID で取得して、そこに get した toolbar を追加するわけですが、先に RemoveFromParent で親から剥がしておきます。 AppBarLayout に追加する際の LayoutParameter に

toolbarParams.ScrollFlags = AppBarLayout.LayoutParams.ScrollFlagScroll | AppBarLayout.LayoutParams.ScrollFlagEnterAlways;

を追加することで連動スクロールが有効になります。ちなみにこれは layout の toolbar.axml で指定してもOKです。

OnLayout では、本来は Toolbar 領域を除いた領域に描画しているところを、全域に描画しなおしています。CoordinatorLayoutを使ったスクロールでは、そうしないと正常な動きにならないようです。 ただし、ここで ContentPage に添付されたプロパティで「スクロールしない」となっていれば、本来のまま Toolbar を除くようにしています。

描画しなおす処理は

PageController.ContainerArea = new Rectangle(0, 0, Context.FromPixels(r - l), Context.FromPixels(b - t - marginTop));

viewGroup.Layout(0, 0, r - l, b - t - marginTop);

の部分で、前者が Forms 側の描画領域の指定で、後者が Platform 側の View のレイアウト処理です。 Xamarin.Forms では Forms の View を利用したレンダリングはこんな感じで両方のレイアウト処理をする必要があります(多分)。

for (var i = 0; i < ChildCount; i++) 以下のあたりは、親クラスの OnLayout を真似ているだけで、正直よくわかってません。

なお Toolbar 自体の描画処理は親側がやってくれているのでサブクラス側では何もする必要はありません。

残念ながら、これだけでは実装は完了しません。もう一手間必要です。

ListView の NestedScroll を有効にする

CoordinatorLayout のスクロール連動は RecyclerView と NestedScrollView でしか有効にならないようで、Forms.Android の ListViewRenderer は RecyclerView ではなく (Android の) ListView なので、そのままでは機能しません。ただ設定で機能させることはできるので、これも CustomRenderer で有効にさせます。 ちょっと設定追加するだけなので Effect で実装しても良いと思います。

これを NestedListView として Forms 側のクラスを用意します。

public class NestedListView:ListView
{
    public NestedListView() : base(ListViewCachingStrategy.RecycleElement)
    {
    }
}

Platform 側に対応する Renderer を用意します。

public class NestedListViewRenderer:ListViewRenderer
{
    public NestedListViewRenderer(Context context):base(context)
    {
    }

    protected override void OnElementChanged(ElementChangedEventArgs<ListView> e)
    {
        base.OnElementChanged(e);

        if(e.NewElement != null)
        {
            ViewCompat.SetNestedScrollingEnabled(Control, true);
        }
    }
}
ViewCompat.SetNestedScrollingEnabled(Control, true);

これを呼べば (Android の) ListView でも スクロール連動できるようになります。

実行

というわけで Page の Xamlを以下に変更して実行すると

<?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:rdr="clr-namespace:Sample.Renderers;assembly=Sample"
    xmlns:prism="clr-namespace:Prism.Mvvm;assembly=Prism.Forms" 
    prism:ViewModelLocator.AutowireViewModel="True" 
    x:Class="Sample.Views.MainPage" Title="MainPage"
    rdr:CoordinatorPage.IsBarScroll="true"  >
    
    <rdr:NestedListView ItemsSource="{Binding ItemsSource}">
        <ListView.ItemTemplate>
            <DataTemplate>
                <TextCell Text="{Binding}" />
            </DataTemplate>
        </ListView.ItemTemplate>
    </rdr:NestedListView>
</ContentPage>

f:id:kamusoft:20181120154406g:plain

無事目的を達成できました。

課題

Android 側は割と無理矢理な実装のおかげでいくつかの課題が残されています。 今回は NavigationPage/ContentPage という組み合わせでだけの対応でしたが、ここに TabbedPage が入ったりすることを想定すると、TabBar も AppBarLayout に移動する処理が必要になってくると思います。 また要素が ListView だけじゃない場合で、AbsoluteLayout でラップしてページ下部に固定のバナーを表示させたいような場面で、不都合があります。例えば以下のような下に60dpの高さのバナーがある XAML の場合

<AbsoluteLayout>
    <rdr:NestedListView ItemsSource="{Binding ItemsSource}" AbsoluteLayout.LayoutFlags="SizeProportional" AbsoluteLayout.LayoutBounds="0,0,1,1">
        <ListView.ItemTemplate>
            <DataTemplate>
                <TextCell Text="{Binding}" />
            </DataTemplate>
        </ListView.ItemTemplate>
    </rdr:NestedListView>
    <ContentView BackgroundColor="Blue" AbsoluteLayout.LayoutFlags="PositionProportional,WidthProportional" AbsoluteLayout.LayoutBounds="0,1,1,60" />
</AbsoluteLayout>
f:id:kamusoft:20181120160728p:plainf:id:kamusoft:20181120160732p:plain

こんな感じになってしまいます。Toolbar 分ページの Bottom がずれるので初期状態では固定バナーが表示されないというような問題が発生します。この問題に関しては AiForms.Effects の Floating を利用して配置すれば解決できます(宣伝)

おわりに

Xamarin.Forms は長らく UI を蔑ろにしていた印象でしたが、2018年は UI系も痒い所に手が届くような更新がそこそこあり、より使いやすくなってきていますので、もっともっと利用者が増えると良いなと思います。ただ、願望としては ListView は、今回紹介したような機能も標準であって欲しいというのと、縦リストだけでなく Grid パターンで配置できたりするようなバリエーションを提供してくれるとより利用者増加につながるのになと思います。なんだかんだで UI は重要で、実装がめんどくさいのでできれば標準で多く用意されていて欲しいものです。

ちなみに Grid 配置できる ListView は AiForms.CollectionView というライブラリを作っていますので、標準実装されるまではよかったらご利用ください。(宣伝その2)

追記

宣伝した途端に、公式に CollectionView が実装されました。 いや自分で望んでおいてなんですが結構苦労したので複雑な気分ですw

docs.microsoft.com

参考