見出し画像

新プロフィールViewのつくりかた - UaaLとJetpack Composeの共存

REALITY

まえがき

こんにちは、REALITYでAndroidエンジニアをしているmits_sidです。

今回は最近リニューアルされたプロフィール画面がどういう作りになっているかについてお話しようと思います。

リニューアルされたプロフィール画面

新しくなったプロフィール画面

バージョン7.18.0 より、自分のプロフィールや他者のプロフィール画面では3Dアバターが表示されるようになり、更に表示中には待機モーションが流れるようになりました。

よりアバターが見えやすい形にリニューアルされたプロフィール画面ですが、この画面で動くアバターの表示部分にはUnity as a Library(通称UaaL)のUnity View表示が使われています。

これまでのREALITYアプリはUaaLのUnity View表示は配信中や視聴中などの全画面表示のみの利用だったのですが、今回のように画面全体の中の一つのパーツとしてUnity Viewを利用するのは初の試みだったりします。

また、加えて画面スクロールに応じてアバターの表示部をアプリバーに隠したりなど、モダンなAndroidアプリによく見られるようなスクロール挙動も実現されています。

Unity ViewとJetpack Compose

そんなプロフィール画面ですが、これに利用されているUnity Viewの取り扱いが結構大変だったりします。

Unity ViewはViewの提供が基本的にはAndroid Viewシステムを前提としたもので動いています(内部的にはSurface Viewをラップしたもの)

一方、AndroidのREALITYアプリにおけるNativeでのView表示周りは旧来のAndroid ViewシステムからJetpack Composeへの移行が進められており、新しく作成する画面では基本的にJetpack Composeでの作成がチーム内では推奨されています。

しかし、Unity Viewは公式ではJetpack Composeに対応したViewが提供されておりません。

プロフィール画面の各種表示要素はUnityではなくNativeでの表示となるため、可能な限りこの辺りはJetpack ComposeでのView作成が出来るとJetpack Composeのメリットを享受できるため理想的です。

そこで今回はプロフィール画面のView構成を大まかに説明しつつ、Unity ViewとJetpack Composeをどう両立したか、モダンなスクロール挙動を実現するためにどういう形にまとめたか、について書いていこうかと思います。

Viewの全体構成について

さて、Unity ViewがどうしてもAndroid Viewシステム前提となる以上、画面のViewのrootとしてはxmlレイアウトである必要があります。

この時点で親Viewはxmlの形にしつつ、子要素としてJetpack Composeを利用するという形を取ることになりそうです。

Android Viewの子要素としてJetpack Composeを利用する場合、ComposeViewをxmlレイアウトに追加し、ソースコード上でレイアウトをインフレートした後に該当ComposeViewを取得しsetContent { }を実行することで任意のComposable関数のViewを表示することが出来ます。

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
    // my_profile_fragment.xmlをインフレート
    binding = MyProfileFragmentBinding.inflate(inflater, container, false).apply {
        // my_profile_fragment.xml内に記載されたcomposeViewにアクセス
        composeView.setContent {
            // プロフィール画面用のComposable関数を呼び出し
            MyProfileInfoScreen(...)
        }
    }
    return binding.root
}

今回はUnity Viewを利用する部分は基本xmlレイアウトとし、それ以外の表示要素やUnity ViewにオーバーレイするUI要素などはJetpack Composeにまとめる形にしました。

スクロール挙動の実現

スクロールに応じて様々な挙動を実現する場合、AndroidアプリではCoordinatorLayoutを利用することが一般的です。

今回のプロフィール画面リニューアルでもCoordinatorLayoutを利用する形を取り、スクロールで隠れるUnity ViewはCollapsingToolbarLayoutの子要素として配置する形を取りました。

シンプルに構造だけ記述すると以下のようなレイアウトになっています。

<CoordinatorLayout>
    <AppBarLayout>
        <CollapsingToolbarLayout>
            <UnityView /> <!-- 3Dアバターの表示部 -->
            <ComposeView /> <!-- Unity Viewにオーバーレイする要素の表示部 -->
        </CollapsingToolbarLayout>
    </AppBarLayout>
    <ComposeView /> <!-- プロフィールの下半分の表示部 -->
</CoordinatorLayout>

これにより、3Dアバター表示部と、それにオーバーレイするUI表示要素はスクロールによって隠れるような挙動を目指しています。

しかし、単純にこの構造にするだけではスクロールによる挙動は実現できません。CoordinatorLayoutで内部にJetpack Composeを利用する場合は更に対応を入れる必要があります。

CoordinatorLayoutとJetpack Composeの連携

後はCoordinatorLayoutのCollapsingToolbarLayoutに必要となるBehaviorを指定し、スクロールイベントに応じて必要な処理をしてもらうだけなのですが、Behaviorでスクロールイベントを受け取るのは一般的には子要素に配置されたNestedScrollViewやRecyclerViewであり、ComposeViewの内部に配置されたLazyColumn等のスクロールイベントはそのままでは受け取ることが出来ません。

スクロール連携するためには「ネストスクロールの相互運用API」を利用し、CoordinatorLayoutとJetpack Composeとの連携を取る必要があります。

今回のようなケースでは rememberNestedScrollInteropConnection()を利用することで親Viewと子ComposeViewで連携を取ることが出来ます。

まず、xmlレイアウトは以下のように作成します。

<androidx.coordinatorlayout.widget.CoordinatorLayout
    android:id="@+id/profile_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/background"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    >

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent"
        app:title="Hello, title!"
        >

        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:id="@+id/toolbar_layout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:contentScrim="@color/onPrimary"
            app:layout_scrollFlags="scroll|exitUntilCollapsed"
            app:scrimVisibleHeightTrigger="0dp"
            >
                
                <!--...-->

        </com.google.android.material.appbar.CollapsingToolbarLayout>

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

ポイントとしては「スクロールによって挙動させるComposeViewにapp:layout_behaviorを指定する」という点です。

次に、アクティビティまたはフラグメントで、子ComposeViewに対してModifier.nestedScrollを指定してやり、rememberNestedScrollInteropConnection()で作成した NestedScrollConnectionをセットアップします。

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        // my_profile_fragment.xmlをインフレート
        binding = MyProfileFragmentBinding.inflate(inflater, container, false).apply {
            // my_profile_fragment.xml内に記載されたcomposeViewにアクセス
            composeView.setContent {
                val nestedScrollInterop = rememberNestedScrollInteropConnection()
                LazyColumn(modifier = Modifier.nestedScroll(nestedScrollInterop)) {
                    item {
                        // プロフィール画面用のComposable関数を呼び出し
                        MyProfileInfoScreen(...)
                    }
                }
            }
        }
        return binding.root
    }

これによりCoordinatorLayoutとComposeView内のLazyColumnが連携して動作するようになります。

Android ViewとJetpack Composeの連携について、より詳細については公式ドキュメントが参考になります。

また、これらのAPIはExperimentalのため、使用の際はご注意下さい。
(自分は大きな変更が入ったらどうしようかとドキドキしています)

手動での各種対応

これで大枠の挙動は実現できたのですが、どうしても難しい箇所についてはAppBarのオフセット変更を検知するリスナーを追加して適宜処理を行います。

AppBarのオフセットはスクロール状況に応じて変化するため、スクロールが一定以上になったら要素を表示・非表示などの切り替えを行うことが出来ます。

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        // my_profile_fragment.xmlをインフレート
        binding = MyProfileFragmentBinding.inflate(inflater, container, false).apply {
            // my_profile_fragment.xml内に記載されたappBarにアクセスしてリスナー追加
            appBar.addOnOffsetChangedListener { appBarLayout, verticalOffset ->
                 // プロフィール画像の残り5分の1までスクロールしたら、fadeスタート
                val fadeOutTriggerHeight = appBarLayout.totalScrollRange.toFloat() * 0.2f
                val triggerOffset = max(
                    0.0f,
                    abs(verticalOffset.toFloat()) - (appBarLayout.totalScrollRange.toFloat() - fadeOutTriggerHeight)
                )

                val value = 1.0f - triggerOffset / fadeOutTriggerHeight

                // 各要素のalpha値などを操作
                uiHoge.alpha = value
                uiFuga.alpha = 1.0f - value
            }
        }
        return binding.root
    }

まとめ

リニューアルしたプロフィール画面を元に、CoordinatorLayoutとJetpack Composeの相互連携をしつつ、UaaLのUnity Viewを利用するという複雑な構成の事例紹介でした。

Androidは公式ドキュメントとAPIがとても充実しており、今回のようなUnity View + CoordinatorLayout + Jetpack Composeの組み合わせという一見突飛に見える組み合わせでも画面を構成することが可能となっていました。

こうした取り組みに興味のある方、是非とも下記リンクよりご応募お待ちしております。