ListDetailPaneを使って一覧と詳細を表示するUIを実装する

本記事はTVer Advent Calendar 2024の11日目の記事です。
10日目の記事は@datahaikuninjaさんによる「TVer広告プロダクト開発タスクのSREになってからの1年を振り返る」でした。

techblog.tver.co.jp

はじめに

昨今のAndroidアプリ開発では大画面への対応が推奨されています。 実際に大画面対応を適用していく中で一覧と詳細を左右で分割して表示したり、関連するコンテンツの一覧を右端に表示したりなど、大画面になってくると1つの画面上で複数の情報を表示したい場合があります。 今回はこのような画面を同じ実装でスマホタブレット、PC、それぞれの画面の大きさに対応できるPaneScaffoldについてお話ししていこうと思います。

https://developer.android.com/static/develop/ui/compose/images/layouts/adaptive/adaptive-many-screens.png

様々な画面に対応する

そもそもAndroidiOSとは違い多種多様なデバイスが存在します。
バイスごとによって画面サイズは異なりますし、マルチウィンドウによってアプリが表示できる領域も動的に変化します。 表示幅に対しての画面の大きさの分類をAndroidではWindowSizeClassとして定義されています。

developer.android.com

WindowSizeClassを利用することでマルチウィンドウなどの動的な画面サイズの変更にも対応することが可能です。 今回お話しするPaneScaffoldはアダプティブレイアウトの一つで、このWindowSizeClassを用いてどのくらいの大きさかを判定し画面に適したUIを提供してくれます。

ListDetailPaneについて

PaneScaffoldの中でもいくつか種類があり、今回は一覧と詳細を表示するのに最適なListDetailPaneScaffoldについてお話しします。

その名の通り一覧と詳細を表示することに適しているPaneScaffoldで、以下のようなレイアウトのUIを簡単に実装することが可能です。

加えてCompact, Mediumであれば以下のように一覧と詳細が別々の画面として表示されます。

このように一覧と詳細の表示が表示領域に合わせて最適な表示に切り替わります。

実装

このセクションでは以下のライブラリを利用します。

implementation("androidx.compose.material3.adaptive:adaptive:1.0.0")
implementation("androidx.compose.material3.adaptive:adaptive-layout:1.0.0")
implementation("androidx.compose.material3.adaptive:adaptive-navigation:1.0.0")

ではListDetailPaneScaffoldを実際に呼び出していきましょう。 ListDetailPaneScaffoldは実態としてはThreePaneScaffoldになっていて以下の3つのPaneを持っています。

  • List: 一覧
  • Detail: 詳細
  • Extra: おまけ、補足など
    • 今回は一覧と詳細にフォーカスするため割愛します

まずはこれら3つのPaneの画面遷移を扱うNavigatorを定義しましょう。

// どのコンテンツを開いているかのkey (Parcelable or IntやStringなどのプリミティブ型)
@Parcelize
class ContentId(val value: Int) : Parcelable
val navigator = rememberListDetailPaneScaffoldNavigator<ContentId>()

// Compactなどにおいては画面遷移が挟まるので、navigator側が戻る処理が可能なら渡す
BackHandler(navigator.canNavigateBack()) {
    navigator.navigateBack()
}

では次に定義したNavigatorを利用してListDetailPaneScaffoldを定義していきます。

ListDetailPaneScaffold(
    modifier = Modifier,
    directive = navigator.scaffoldDirective,
    value = navigator.scaffoldValue,
    listPane = { /* 一覧画面 */ },
    detailPane = { /* 詳細画面 */ },
)

これで画面遷移の役割を担うNavigatorと実際のUIであるListDetailPaneScaffoldの実装自体は完了です。

しかし、この状態だと画面遷移のアニメーションが一切ありません。

PaneScaffoldのNavigatorはNavigationComposeとは異なり、AnimatedPaneを表示したいComposableにwrapすることで画面遷移時のアニメーションをつけることが可能になります。

listPane = {
    AnimatedPane {
        // 一覧画面の実装
    }
},
detailPane = {
    AnimatedPane {
        // 詳細画面の実装
    }
}

カスタマイズ

現在出ている最新のstableである1.0.0では、よくあるユースケースに合わせてPaneScaffoldを呼び出せるようになることがメインとなっていて、実装側でカスタマイズできる要素がほとんどありません。
執筆時点の最新である1.1.0-alpha07ではAnimatedPaneに独自のアニメーションを設定できたり予測型戻るのサポートなどが追加されているので1.1.0を利用できる環境であれば1.1.0を利用することをお勧めします。

予測型戻るジェスチャー

Android15以降で開発者オプションから外れオプトインで利用できるようになった予測型戻るジェスチャーですが、ListDetailPaneScaffoldでは対応していない状態です。 予測型戻るは1.1.0のNavigableListDetailPaneScaffoldで対応されていて、それに合わせてNavigatorの遷移処理がsuspendになっていたりするので注意が必要です。

val navigator = rememberListDetailPaneScaffoldNavigator<ContentId>()
NavigableListDetailPaneScaffold(
    modifier = Modifier,
    navigator = navigator,
    listPane = { /* 一覧画面 */ },
    detailPane = { /* 詳細画面 */ },
)

NavigableListDetailPaneScaffoldを利用する上で特に注意する点としてBackHandlerの実装が不要という点です。予測型戻るへの対応のためNavigableListDetailPaneScaffoldの内部で独自にBackHandlerを実装しているためListDetailPaneScaffoldで実装したBackHandler関連の処理は不要になります。

NavigableListDetailPaneScaffoldでは渡されたNavigatorを元にdirectiveやvalueを裏側で決定するため、特段そこに対して変更したいモチベーションがなければNavigatorListDetailPaneScaffoldを利用するのが一番シンプルに実装できます。

画面遷移アニメーション

1.1.0ではAnimatedPaneにアニメーションを独自に設定することが可能になっています。実際に画面遷移のアニメーションを変更してみましょう。
NavigationComposeと同様にEnterTransitionとExitTransitionをAnimatedPaneに渡してあげることでそのアニメーションでの遷移が可能になります。

AnimatedPane(
    enterTransition = fadeIn(),
    exitTransition = fadeOut().
) {
    // 画面の実装
}

まとめ

今回はListDetailPaneScaffoldを利用した一覧と詳細の大画面対応を実装してみました。実装自体はそこまで難しいものではなく容易に扱うことが可能です。
またListDettailPaneの実態はThreePaneScaffoldなので、一覧と詳細を表示するユースケースでなくても同様に実装することが可能です。ref

PaneScaffoldはExperimentalな機能で記事自体もないので、この記事が皆さんの一助になれば幸いです。

明日のTVer Advent Calendar 2024@okamiiiさんによる「テレビ配信サービスだけではないTVer」です。お楽しみに!

2023年振り返り

ひっそりと振り返り

仕事

今年の3/4はAWSでのインフラ構築(?)やNestJS + TypeScriptでバックエンドとかやってたんですが、転職して残りの1/4はAndroidエンジニアに戻り後半からはAndroidを楽しくやらさせていただきました 。
来年も引き続きAndroidやっていきます👊

買ってよかったもの

ゲーム

今年も色々なゲームをやったんですが、特に印象に残ったのが太閤立志伝

www.gamecity.ne.jp

小学生の頃に父親がPS2でやっていたのを見てプレイし始めた思い入れのある作品で、また一からやり始めました。

PC版だと専用スクリプトでイベントを作成することができるので、試しに作ってみたら案外いけました。(JIS使わないといけないのがネックですが...)

1-2週間くらいでプレイ時間が40時間越えくらいになっていたので、だいぶ沼ゲーです。

お酒

友達の付き添いで酒屋に寄った際にウィスキーに興味を持ち、響を買ったところ沼にハマりました。 知り合いとバーに行ったりして色々な種類を飲んだんですが、今でも響が一番好きです。 丸氷の製氷器なんかも買って宅飲みを楽しんでます。

旅行

今年はたくさん旅行したので雑多に

大阪

1月下旬に大阪に行ってきました。ヨルシカのライブが大阪であり大阪自体も行く機会が少ないため、ちょうどよい機会だなと思い遠征含めて楽しんできました。

神奈川

割と近場の横浜へ。前職の後輩が横浜近辺に住んでいたので観光案内をしてもらいながら最後は後輩宅でタコスパーティ。赤レンガ倉庫や中華街などを一日で周り、かなり充実感ある一日を過ごせました。

宇都宮

ただただ宇都宮で餃子が食べたくなり無計画で友達と宇都宮旅行。餃子食べた後、都度都度次行くところを決めて一日満喫。ぶらり途中下車みたいな旅であまりない経験でしたが、とても楽しみました。まさしく一期一会の旅でした。

新潟

高所恐怖症でなんだかんだでウィンタースポーツをやったことがなく、友人指導のもとスキーに初挑戦。 めっちゃ楽しかったんですが、来年は年始に予定が詰まっており行けない可能性があるのが悔やまれる...

宮城

仙台住みの友人とヨルシカのライブに行くため仙台遠征。牛タンは激混みしそうなので夜に焼肉で牛タン満喫しました。 仙台城にも行ってみたんですが、意外に道のりが困難でしんどく戦国時代での城攻めが難しかったんだろうなと思いました笑

北海道

ウィスキーにハマり余市蒸溜所で蒸留所限定モデルが買いたくなり北海道に訪れました。蒸留所以外にも洞爺湖周りも見て回り、自分の中では北海道をちょっぴり満喫できた旅行でした。

バーが併設されてるホテルに泊まり、最高の旅行でした。

福島

大学の頃の友人と、いつかまた会津に行こうと話をして未だ達成できていなかった会津旅行が遂に達成。 学生時代毎週食べていた懐かしのラーメンを食べて大学時代に思いを馳せる旅行でした。

福岡・大分

一昨年、去年とISUCON旅行ができなかったので今年は開催。 チームメンバーの1人が福岡に住んでいたのと、自分自身も福岡に行きたかったのもあり福岡・大分旅行をすることに。

ISUCONは別府を満喫しながら挑戦しました。

終わりに

今年はフットワークを少しでも軽くしたいなと思い、旅行を増やそうと思ったのですが改めて振り返るとたくさん旅行したなという印象です。 今年やりきれなかったことややりたかったことを目標に来年も頑張っていきたいなと思います。

Compose for Desktopでデスクトップアプリを作って画面遷移をする

この記事はKotlin AdeventCalendarの4日目の記事です

qiita.com

Compose for Desktopとは

www.jetbrains.com

端的に説明すると、JetBrainsが提供するKotlin向けのUIフレームワークです。
AndroidのUIフレームワークであるJetpackComposeを利用し、各Platformにネイティブなデスクトップアプリを作ることができます。

簡単なアプリを作ってみる

まず最初にIntellijからプロジェクトを作成します。

これだけで下記のような画面が作成できています

UI周りはJetpackComposeと同様に書くことができますが、今回は画面遷移にフォーカスした話のため割愛します。

画面遷移してみる

本来JetpackComposeであれば navigation-compose を利用することで画面遷移を実装することができるのですが、 navigation-composeAndroidでしか使えないためCompose for Desktopで使うことはできないです。 公式的には特定のライブラリを強制していないですが、参考実装としてDecomposeを使って画面遷移の実装例を上げてくれています。

The Jetpack Compose navigation library (navigation-compose) is an Android-only library, and so can not be used together with Compose for Desktop. Our general attitude is not to “force” people to use a particular first-party library. However there are third-party libraries available. One could consider Decompose as possible solution.

ref) https://github.com/JetBrains/compose-jb/tree/master/tutorials/Navigation

ということでDecomposeを使って画面遷移を実装していきます。

今回はRootでの遷移先として HomeHelloWorld を設定します。

interface RootComponent {
  val childStack: Value<ChildStack<*, Child>>

  // 遷移処理
  fun navigateToHome()
  fun navigateToHelloWorld()

  // 画面
  sealed class Child() {
    object Home : Child()
    object HelloWorld : Child()
  }
}

具体的な実装を書いていきます。

class DefaultRootComponent(
  componentContext: ComponentContext,
) : RootComponent, ComponentContext by componentContext {

  private val navigation = StackNavigation<Configuration>()
  override val childStack = childStack(source = navigation, initialStack = { listOf(Configuration.Home) }, childFactory = ::child)

  private fun child(configuration: Configuration, componentContext: ComponentContext): RootComponent.Child {
    return when (configuration) {
      is Configuration.Home -> RootComponent.Child.Home
      is Configuration.HelloWorld -> RootComponent.Child.HelloWorld
    }
  }

  override fun navigateToHome() {
    navigation.replaceCurrent(Configuration.HomePage)
  }

  override fun navigateToHelloWorld() {
    navigation.replaceCurrent(Configuration.HelloWorldPage)
  }

  // Decomposeに合わせた画面の設定
  sealed class Configuration : Parcelable {
    @Parcelize
    object Home : Configuration()
    @Parcelize
    object HelloWorld: Configuration()
  }
}

ではここから画面遷移の実装を入れていきます

@Composable
fun RootContent(component: RootComponent) {
  val childStack by component.childStack.subscribeAsState()
  val lifecycle = LifecycleRegistry()

  Children(
    stack = childStack,
  ) {
    when (it.instance) {
      is RootComponent.Child.Home -> HomePage(component)
      is RootComponent.Child.HelloWorld -> HelloWorldPage(component)
    }
  }
}

subscribeAsStatechildStack が更新するたびにRootContentがレンダリングされ、現在の子要素が表示されるようになります。 あとは navigateToXXX を画面遷移したいタイミングで実行すれば完成です。

引数を渡したい場合

単純な画面遷移の実装は完了しました。ただ商品一覧から商品詳細に遷移する場合のように、画面遷移では特定の引数を渡して上げたくなるケースが多々あります。 その場合下記のように実装していくことで解決できます。

まず画面遷移に使う navigateToXXX で引数を渡して、Child で該当の引数を受け取れるように修正していきます。

interface RootComponent {
  // etc

  // 画面
  sealed class Child() {
    object Home : Child()
    data class HelloWorld(val text: String) : Child()
  }
}

class DefaultRootComponent(
  componentContext: ComponentContext,
) : RootComponent, ComponentContext by componentContext {

  //etc

  override fun navigateToHelloWorld(text: String) {
    navigation.replaceCurrent(Configuration.HelloWorldPage(text))
  }

  // Decomposeに合わせた画面の設定
  sealed class Configuration : Parcelable {
    @Parcelize
    object Home : Configuration()
    @Parcelize
    data class HelloWorld(val text: String): Configuration()
  }
}

最後に伝播して渡ってきた引数を画面切り替え時に利用します。

@Composable
fun RootContent(component: RootComponent) {
  val childStack by component.childStack.subscribeAsState()
  val lifecycle = LifecycleRegistry()

  Children(
    stack = childStack,
  ) {
    when (val current = it.instance) {
      is RootComponent.Child.Home -> HomePage(component)
      is RootComponent.Child.HelloWorld -> HelloWorldPage(component, current.text)
    }
  }
}

こうすることで実際の画面遷移実行時の引数が画面切り替え時に利用することができます。

まとめ

以上がCompose for Desktopの初歩的な実装とDecomposeを利用した画面遷移の実装でした。 今回はRootのみの画面遷移を例として上げましたが、場合によっては子の画面遷移Componentを作成して画面遷移をネストできるので状況に応じて使っていければと思います。

今年の振り返り 2021

f:id:slme9364:20211221082619j:plain

買ってよかったもの

  • WF1000-XM4
  • Pixel6
  • Fire TV Stick
  • Nile洗顔 & 化粧水
  • Razer BlackWidow V3 TKL
  • 音楽的同位体 可不(KAFU)
  • KORG microKEY Air-25

読んでよかった本

ゲーム

イベント

仕事, 技術

DMMからスタートアップに転職しました
Android -> Backendになり今はKotlinでSpringNativeやってます
触ってる技術スタックはこんな感じ

  • Kotlin
  • SpringNative
  • Exposed
  • GCP

現在フル稼働できるエンジニアは自分だけなのでwebフロント以外は自分が見てます

プライベート

DTMしたりゲームしたり楽器弾いたりと、色々と趣味に没頭しまくった一年でした。
ISUCONやSECCON Beginnersに参加したりと色々挑戦したりしますが、それ含めても趣味に没頭しまくったと思います

来年の抱負

  • Flutter
  • Androidネイティブやっていき
  • DTMちゃんとやってギターも弾く

Kotlinの型周りのちょっとした話

この記事は Aizu Advent Calendar 2021 - Adventar20日目の記事です

adventar.org

特にネタがなかったので実際のプロダクトコードで罠にハマった話をします
記事にあるコードはすべてKotlinです

型パラメータ

Kotlinだとおなじみのスコープ関数の let を例に上げてみます

public inline fun <T, R> T.let(f: (T) -> R): R = f(this)

このとき T, R が型パラメータにあたり、コンパイル時に実際の型が当てはまります

またこのとき、 T, R に上限境界がないため、任意な型になりえます
KotlinのNullableを型パラメータ上で表現したい場合は、上限境界を Any にしておきましょう

fun <T> functionA(value: T): String? {
    return value?.toString()
}

fun <T : Any> functionB(value: T?): String? {
    return value?.toString()
}

型消去とreified

上記の型パラメータは型消去があるため実行時には基本的に参照することができません
一応Kotlinには reified があるため実行時に参照することが可能です

inline fun <reified T> function(value: T): Boolean {
    return value is T
}

reified を使うと以下のことができます

  • is, asなどの型チェック、キャスト
  • ::class, ::class.javaなどのリフレクション、型クラス取得
  • 別の関数への型パラメータとしての利用

注意点として inline fun なのでインライン展開されるのを意識してください
(例えばJavaから呼び出しとかはできないです)

罠にハマった話

ここがこの記事を書こうと思った本題です サンプルコードなので命名や実装は適当です🙏

open class C()

open class D() : C()

open class A<T : C>(vararg value: T) {
    val clazz = value::class.java.componentType
}

class B<T : C>() : A<T>()

fun main() {
    val a = A<D>()
    val b = B<D>()
    
    println(a.clazz) // class D
    println(b.clazz) // class C
}

脳死で書いていたら無事罠にかかりまして、b.clazz はDではなくCなんですよね
reified を使って回避しようにも、コンストラクタに使えないのでダメですね
色々解決策はありましたが、結構コード自体闇が深くなりそうなので結果的には全く別の実装をしました
戒めとして記事に残します

今年の振り返り 2019

買ってよかったもの

  • ViewSonic 32インチ ディスプレイ (アンチグレア)
  • モニターアーム
  • 横幅2mくらいのデスク
  • 珪藻土バスマット
  • ノイズキャンセリングワイヤレスイヤホン
  • 人工観葉植物 + 間接照明
  • ダメ着4G (着る毛布的な)

今年読んだ中でよかった本

  • Web API The Good Parts
  • DEEP WORK
  • UNIXという考え方
  • Android テスト全書
  • わかる!ドメイン駆動設計 ~もちこちゃんの大冒険~

ゲーム

イベント

  • マジカルミライ2019
  • TGS
  • DIVE XR FESTIVAL
  • ほんだのバイク 1周年イベント
  • グラブルフェス
  • UNSER Tour at Tokyo DOME (UVERWorld)
続きを読む

Gradleでライブラリのアップデート自動検知

この記事はAizu AdventCalendar 8日目の記事です。

adventar.org

はじめまして。元会津大学生のスルメです。
いまはDMMでAndroidエンジニアやってます。

はじめに

Gradleでマルチモジュールで開発しているとライブラリのバージョン管理を一か所でやりたくなります。 そのためversion.gradleといったようなファイルで一括管理をして各モジュールでそこで定義した変数から値を取得しるような実装をします。 このようにするとversion.gradleに使用ライブラリや言語のバージョンなどが集約されて変更が容易になります。 ただし、version.gradleはあくまで変数が書いてある場所でしかないのでIntellijやAndroidStudioなどでライブラリのアップデートを検知できません そのため今回はCI時にライブラリのアップデートを自動で検知す仕組みを導入したのでそれについて話していきます。

CIで自動検知

今回導入したものはこちらです。

github.com

こちらを導入するとgradle taskが新しく増えます

gradle dependencyUpdates
./gradlew dependencyUpdates

これだけでライブラリのアップデートを自動で検知してくれます。またデフォルトだっとtxtをはいてくれますがオプションを付けるとjson, xmlをはいてくれます。 これで準備は完了です。 あとは .circleci/config.yml でこのコマンドを実行すればライブラリのアップデートを自動で検知してくれます。 ただこの段階だと検知した物を外に投げる手段がありません。そのためDangerを用います。

github.com

あとはjsonで出力したものをdanger側のコードで解析してPRのコメントに流せば完成です!!!

./gradlew dependencyUpdates -Drevision=release -DoutputFormatter=json

f:id:slme9364:20191203083326p:plain

注意事項としてgradle-version-pluginsは初期設定だと最新版を検知するので設定を加えないとα, β, rcなども対象に含まれます。個人プロダクトの場合は問題ないかもしれませんが業務プロダクトだとそうもいかないのでrelease版のみを検知するようにしましょう。

公式docにも書いてありますが特定versionをrejectすると動きます。

def isNonStable = { String version ->
  return ['ALPHA', 'BETA', 'RC', 'EAP', 'DEV'].any { it -> version.toUpperCase().contains(it) }
}
dependencyUpdates {
  rejectVersionIf {
    isNonStable(it.candidate.version)
  }
}

ちなみにoutputしてきたjsonを解析して警告するとこのDangerfileの実装です。Ruby力は皆無なのでコードがクソなのは許してください

require 'json'
Dir.glob("**/dependencyUpdates/report.json").each { |report_file_path|
  results = File.open(report_file_path) do |io|
    JSON.load(io)
  end
  available_update_lib = results["outdated"]["dependencies"]
  available_update_lib.each { |lib|
    report = "#{lib["group"]}.#{lib["name"]}: #{lib["version"]} -> #{lib["available"]["release"]}"
    warn(report)
  }
}