AndroidViewの思い出深いView3選

こちらの記事は [NewsPicks Advent Calendar 2024](https://qiita.com/advent-calendar/2024/newspicks) の22日目の記事です。

こんにちは。NewsPicks Androidアプリエンジニアの sefwgweo です。

## はじめに
今回は、タイトルにもある通りAndroidで様々なViewを作成してきましたが特に思い出深かったものをコードとともに紹介していこうと思います。
今更ComposeではなくAndroidView?と思われる方も多いかと思いますが、仕様を実現するために頭をひねらすという行為は決して無駄にはならないと思って記事を書いてみました。

構成はよくあるTabLayout + ViewPager2 + RecyclerViewとなっています。

また、今回もいつものようにコードを以下に載せてあります。   
https://github.com/sefwgweo/CustomDesignTabAndMoreLayout

## 目次
0. 環境構築
0. 見た目をフルカスタマイズ可能なタブレイアウトの作り方
0. 横スクロール時、コンテンツが滑らかに拡大表示するViewの作り方
0. 透明Activityの作り方と活用方法

## 環境構築
前提として、今回のお話は全てComposeではなくAndroidViewとなります。
また、毎年恒例でこの記事のために新規でAndroidStudioのプロジェクトを作成して、イチからコードを書くようにしています。

では本題の環境構築に入ります。
今回はAndroid Studio Ladybug | 2024.2.1をMacOSのsequoiaで使っていますが、いつの日からかAndroidStudioではComposeで作ることが標準となっていたため、AndroidViewを使ってビルドするためには少しだけ手間をかける必要がありました。

具体的には以下2点に留意する必要がありましたが、無事ここまでで環境構築はOKです
1. AndroidViewに関するライブラリを追加する([build.gradle.kts](https://github.com/sefwgweo/CustomDesignTabAndMoreLayout/blob/main/app/build.gradle.kts))
    - 具体的には `androidx-recyclerview` `androidx-constraintlayout` `androidx-viewpager2` `androidx-appcompat` `androidx-activity-ktx` `androidx-fragment-ktx`のあたりになります
1. 上記ライブラリに依存するライブラリのバージョン調整をする([libs.versions.toml](https://github.com/sefwgweo/CustomDesignTabAndMoreLayout/blob/main/gradle/libs.versions.toml))

また、Recyclerviewに関しては許可をいただき以下を使っています。
https://qiita.com/ko2ic/items/cf77f8f73986270dc3b0

## 見た目をフルカスタマイズ可能なタブレイアウトの作り方
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/541072/85049ff9-6605-fbc9-b586-bcfc06a2a42d.png" width=40%>

タブのカスタムについては以前書いた[AndroidのTabLayoutをカラフルにカスタマイズしてみた](https://tech.uzabase.com/entry/2022/12/07/101953)というものがありますが、今回と異なる点としては、添付画像にあるようにタブ内に画像を入れたりインジケーターの色や背景色をタブ毎に指定する箇所が以前と異なっているところにあります。
また、長らくタブレイアウトというのはOSの領域だった記憶があり、あまり見た目に関しては自由度が高くなかったという印象が強かったため今回選出しました。

実装時のポイントとしては以下のクラスでタブ用のTabItemViewModelを生成している箇所になります。以下な実装にすることで、ViewBindingを用いて色を変えたり画像を出したりが容易に実現出来ています。
[TabFragment.kt](https://github.com/sefwgweo/CustomDesignTabAndMoreLayout/blob/main/app/src/main/java/com/example/customizeddesigntabandmorelayout/ui/TabFragment.kt)

```kotlin
private fun initUIComponent() {
    binding.toolbar.title = getString(R.string.app_name)
        
    tabList = viewModel.fetchTabs()
        
    // viewModelから取得したタブ情報のリストを元にtabItemViewModelを生成
    val tabItemViewModel = tabList.map {
        TabItemViewModel(it)
    }
    val pagerAdapter = TabPagerAdapter(this@TabFragment, tabList)
    binding.pager.apply {
        adapter = pagerAdapter
        offscreenPageLimit = pagerAdapter.itemCount
        registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
            override fun onPageSelected(position: Int) {
                super.onPageSelected(position)
                tabItemViewModel.forEachIndexed { index, viewModel ->
                    binding.tabLayout.setSelectedTabIndicatorColor(
                        tabItemViewModel[position].indicatorColor
                    )
                }
            }
        })
    }
    TabLayoutMediator(binding.tabLayout, binding.pager) { tab, position ->
        val binding = TabItemBinding.inflate(layoutInflater)

        // tabItemViewModelとXML(tab_item.xmlを紐づけ)
        binding.viewModel = tabItemViewModel[position]
        tab.customView = binding.root
    }.attach()
}
```

## 横スクロール時、コンテンツが滑らかに拡大表示するViewの作り方
動作イメージとしては以下な感じとなります。
![Videotogif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/541072/1cee8bfc-1b96-134e-9320-a94fa6535219.gif)


滑らかに拡大しているように見せるため、ガウススケールファクターを使用して実現しています。
実装では以下でガウススケール計算を行っています
[BindingAdapters.kt](https://github.com/sefwgweo/CustomDesignTabAndMoreLayout/blob/main/app/src/main/java/com/example/customizeddesigntabandmorelayout/ui/BindingAdapters.kt)

```kotlin
private fun getGaussianScale(
    view: RecyclerView,
    childCenterX: Int,
    minScaleOffset: Float,
    scaleFactor: Float,
    spreadFactor: Double,
): Float {
    val recyclerCenterX = (view.left + view.right) / 2
    return (
        Math.E.pow(
            -(childCenterX -recyclerCenterX.toDouble())
            .pow(2.toDouble()) / (2 * spreadFactor.pow(2.toDouble())
        )
    ) * scaleFactor * 0.05 + minScaleOffset).toFloat()
}
```

## 透明Activityの作り方と活用方法
動作イメージとしては以下な感じとなります。
![Videotogif (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/541072/6ac4be8d-8b9f-3113-cc5b-f930a190c51c.gif)


こちらは、以前書いた[状況に応じて透明ActivityなThemeにOverrideする方法](https://qiita.com/sefwgweo/items/94cca1907bd74ef35e3f)を実際に実装してみたものになります。

透明にしている箇所は以下です
[DetailActivity](https://github.com/sefwgweo/CustomDesignTabAndMoreLayout/blob/main/app/src/main/java/com/example/customizeddesigntabandmorelayout/ui/DetailActivity.kt)

```kotlin
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
    // 以下は透明なActivityにするための処理(OS11以上)
    window.setBackgroundDrawableResource(android.R.color.transparent)
    lifecycleScope.launch(Dispatchers.IO) {
        // メインスレッドで実行するとANRになるため、IOスレッドで実行
        setTranslucent(true)
    }
 }
```


下スワイプで後ろにあるページが見られるようにしているのが以下の実装となります。

[DetailActivity](https://github.com/sefwgweo/CustomDesignTabAndMoreLayout/blob/main/app/src/main/java/com/example/customizeddesigntabandmorelayout/ui/DetailActivity.kt)

```kotlin
private fun initSwipeUI(binding: ActivityDetailBinding) {
    val param: CoordinatorLayout.LayoutParams = binding.container.layoutParams as CoordinatorLayout.LayoutParams
    param.behavior = BottomSheetBehavior<FrameLayout>()

    BottomSheetBehavior.from(binding.container).apply {
        state = BottomSheetBehavior.STATE_EXPANDED
        isHideable = true
        addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
            override fun onStateChanged(bottomSheet: View, newState: Int) {
                when (newState) {
                    BottomSheetBehavior.STATE_COLLAPSED -> {
                        state = BottomSheetBehavior.STATE_HIDDEN
                        finish()
                        overridePendingTransition(0, 0)
                    }

                    BottomSheetBehavior.STATE_HIDDEN -> {
                        return
                    }

                    else -> {
                        // noop
                    }
                }
            }

            override fun onSlide(bottomSheet: View, slideOffset: Float) {
                val upperThreshold = 0.7f
                if (state == BottomSheetBehavior.STATE_SETTLING && slideOffset < 1) {
                    if (slideOffset >= upperThreshold) {
                        // 位置が0.7以上の場合は、EXPAND状態にする
                        state = BottomSheetBehavior.STATE_EXPANDED
                    }
                }
            }
        })
    }
}
```

BottomSheetDialogFragmentを使っても同様の事は構成によっては可能ですが、DialogFragmentとしての縛りだったり、弊社のように原則1ページ1Activity1Fragment構成のケースも考慮した実装となっています。

## おわりに
今回も結構マニアックなケースについて取り上げてみましたがいかがだったでしょうか

今後はAndroidViewに関して基本的には増えることはなく、順次Composeで書き直していくことになります。
冒頭でも触れた通り、仕様を満たすために様々な実験・調査・検証等をへて、その中で可能な限りベストな方法を選択する、というプロセスはそれがComposeだろうとそうでなかろうと大きく変わる事はないと思っていますので、そのあたりが参考になれば幸いです。