Kotlin/Composableで構成
実際に画面を構成してみる
では、画面に必要な要件を挙げていって、実際にComposableな要素を詰め込んでいってみましょう。今回作るのは簡素なファイラーです。とりあえず、ファイルを一覧できて、ファイルをクリックしたらファイルを開くのに必要なIntentが飛ばせればいいです。Intentを「飛ばす」って言ってみましたが、みんな何て言ってるんだろう?Intentを実行する?発行する?startActivityする?インテンドする?実際のところメディアファイルが入った特定のフォルダの中身を表示するだけなので、startActivityにContextとIntentを渡すだけだったりします。Jetpack Composeとは関係ないので、アプリの詳細は割愛します。
ファイルの一覧というと以前のやり方だったらRecyclerView辺りを使ってたでしょうが、それに代わるものがあるのでしょうか?ファイル名も表示したいので最初から入っていたTextはきっと使うはずです。あとはボタンみたいなタッチできる要素があれば、ファイルを選択して実行できるんじゃないでしょうか?
というわけで最初から入っていたGreetingはもう要りません。代わりに一つのファイルを表すようなカスタムComposable(勝手な呼び名)が必要になるかもしれません。あっ、Greetingを捨てる前に、気になっていた「自分に自分を入れ子する」をやってみました。ビルドは通ったので、それ自体は許されているようです。ただし、入れ子の入れ子に入れ子が入ってそれにもまた入れ子が入って・・・無限入れ子Composableが発生して画面が立ち上がらず、最終的にエラーを吐いて停止しました。入れ子の入れ子はやってもいいけど、無限入れ子ループにならないように適切な抑止策を実装する必要がありそうです。
基本を学ぶ
大体必要そうなパーツが思い描けたので、実際に使えるパーツを探してみましょう。これまでのレイアウトxmlを使った画面構成を作る時はエディタがついていたので、ビューを選択するメニューからボタンならボタン、テキストラベルならテキストラベルと選択して、マウスで配置すればよかったのですが、新しいやり方では「Design」窓で現在のレイアウトを確認できても、編集まではできなそうに見えます。(知らないだけでできるかもですが)それなら先にちょっとどんなパーツがあるのか勉強してみましょうか。
公式に基本を教えてくれそうなので、↑これに従ってみましょう。
ふむふむ、先に登場していたSurfaceは背景色を持てるのか。領域に色を付けたいときSurfaceでラップしろとのこと。Composable中にComposableを配置するとき、親Composableで子をラップするって言うのか~。あっ、Composableな要素のことをしれっとコンポーネントって呼んでますね。今度からそう呼ぼう。
Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.primary ){...}
Surfaceのcolorをprimaryにしてみましたよ。ほう、すると中のTextのテキスト色まで自動的に見やすい色に変わってくれると。これはアプリのテーマを設定と関係がありそうです。確か最初からアプリ名がくついたHogeThemeっていうコンポーネントで全体がラップされてましたもんね。この標準テーマはあったら便利な設定が最初から用意されていて、自分流に細かく再定義もできる使える子とお見受けしました。とりあえず、今は細かくは触れずに便利さだけを享受していきます。
修飾子
「Compose UI 要素は、省略可能な modifier パラメータを受け入れます」って書いてあるのを発見しました。さっきコンポーネントって呼んでたのに今度は「Compose UI 要素」ですか・・・そんなコンポーネントのプロパティ的なやつがパラメータって呼ばれてますね。さっきのSurfaceの色(color)やTextのテキスト(text)なんかもパラメータですね。そのパラメータの中にちょいちょいModifierってのが入ってきてました、ちょっと気になっていましたが、修飾子と呼ばれるこれを使って、いろんな指定ができるそうです。サンプルコードではパディングをいじってますね。
Text( text = "Hello $name!", modifier = modifier.padding(24.dp) )
24.dpっていう単位付き数値の表現も今は深く考えないでおきます。HTMLっぽく考えるとこの修飾子っていうのはスタイルシートに似ているかもしれません。実際に「位置揃え、アニメーション化、配置、変換、クリック可能 / スクロール可能にする処理など」いろんな修飾が可能ってことらしいですよ。本物のCSSと似てくれていたら、学習が簡単に済みそうでいいんだけど、どうなんでしょうね?
Composableの再利用
私がカスタムUIとか勝手に呼んでいたことも、再利用という言葉と共に登場しました。UI構造が複雑になるとネストも深くなって、ソースの可読性も悪くなるよね、ってことで@Composableアノテーションをつけた関数で部品化しちゃうわけですね。大体思った通り!部品化すればコードの重複も回避できると。
レイアウト
ここでColumn, Row, Boxというレイアウト用のコンポーネントが出てきました。以前はLinearLayoutをhorizontalやverticalに方向指定してビューを並べていましたが、似たようなものでしょうか。ぶっちゃけColumn, Rowって簡潔に表せるこっちの方が好きです。それで、このレイアウト要素の中ではfor文を使った列挙なんかもできるそうです・・・これは私の必要とするファイルの列挙に使えそうなコンポーネントじゃないですか!
@Composable fun Directory(files: List<String>, modifier: Modifier = Modifier) { Column { for (file in files) { Text( text = file, modifier = modifier.padding(24.dp) ) } } }
それではってことでGreetingを捨てて、Dierctoryという名のコンポーネントを用意してみました。ファイルが詰まったコンポーネントになるからDirectoryです。こんな感じの命名方針でいいでしょうか?(何かにすぐバッティングしそう)Directoryはファイル名のリスト(List
var files:List<String> = listOf("test.mp4","hoge.mp4")
とりあえず、適当にリストを埋めてみると、二つのファイル名が画面に出てきました。このリストをこの後、更新するんですが、データの更新後、描画を更新する方法はどうなってるんでしょうか?RecyclerViewのときはnotifyDataSetChangedみたいなnotify系メソッドを呼んで再描画してもらってましたが、似た仕組みがきっとあるはずですよね。
mutableStateOf
ファイル名のリストを更新できるようにする仕組みはMutableStateというものでした。「変更される状態」を意味するわけなので、きっと値の変更を感知してくれるのでしょう。こいつが変更を感知したら、こちらで何かしらnotifyすることもなく画面の更新までしてくれるってことでしょうか。(便利!)使い方を誤ると無駄に画面をリフレッシュしまくってアプリが重たくなる危険性を孕んでいそうにみえますが、今は深く考えないでおきましょう。
private val filesState = mutableStateOf(listOf("test.mp4","hoge.mp4"))
先にファイル名のリストの変数を作っていましたが、これをMutableState>に変更しました。mutableStateOf・・・~Ofとしておけば、なんでもできてしまうKotlinさん。
setContent { HogeTheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.primary ) { Directory(filesState) } } }
setContentの中でもDirectoryにfilesStateを渡します。
@Composable fun Directory(filesState: MutableState<List<String>>, modifier: Modifier = Modifier) { val files by filesState Column { for (file in files) { Text( text = file, modifier = modifier.padding(24.dp) ) } } }
Directoryに渡したMutableStateはbyを使って管理対象のファイルリストを引っ張りだせるようです。
val files = filesState.value
↑これでも同じことができそうですが、byを使うとみんな英字で書ける!Kotlinってそういう文法を目指してたりするんですかね?それで、これだけの変更で、データの変更がビュー部分に反映されるようになりました。すごい!
filesState.value = listOf("betsuno.mp4","video.mp4")
ちなみにファイルリストはこんな風にvalueを変更したらできました。更新の方もbyを使って書けそうでしたがrememberなる新しいキーワードが絡んできそうだったので一旦忘れておきます。mutableStateListOfなんていうListとして扱いやすくなりそうなやつもありそうでした。これも今は必要性を感じないけど、リスト捜査の効率をあげたくなったら思い出したいですね。
レイアウト構成最終段階
ファイルを一覧できるようになったので、後はボタンでも押して実行できるようになったら完成です。
Directory └ Column └ Row └ Text └ Button └ Row └ Text └ Button └ Row └ Text └ Button ...
↑こんな構成を考えてみましたところ、いくつか問題が発生しました。
- Buttonというそのものずばりなコンポーネントがあったので配置してみたら、祖先要素のSurfaceの背景色をprimaryカラーにしちゃっていたのでボタンの背景色と被り、同化してしまった
- ファイル名が入ったTextが長い時、Rowに入らないのかButtonが消えた
- Buttonの縦位置が気持ち悪い(下寄りになってたので中央寄せにしたい)
- Buttonのクリックをどう表現するの?
一つずつ解決していきましょう。
まずSurfaceの背景色がprimaryカラーである必要が一つもないので元のbackgroundカラーに戻しておきましょう。
ボタンの位置が下寄せになってたのはRowの設定で何とかなりそうです。
Row( verticalAlignment = Alignment.CenterVertically )
Buttonがはみ出して消えるのは、Textの最大幅を画面の75%程度に抑えてボタンが描画できるスペースを確保しました。
Text( text = file, modifier = modifier.padding(24.dp).fillMaxWidth(0.75f) )
・・・あとはボタンのクリックか・・・。
ボタンを押したら実行
ファイル名を渡したら然るべきIntentを飛ばしてくれるメソッドがMainActivityに既に備わっています。
class MainActivity : ComponentActivity() { fun runFile(file: String) { ... } }
そしてDirectoryコンポーネントの中にはButtonがあるので、これが押されたら、さっきのrunFileメソッドを呼べばいい、それだけのことなのですが、MainActivityとDirectoryでは同じファイルにあってもスコープが違うので、Directoryの中で自由にrunFileメソッドを呼べないのでした?こういうときはどうするのでしょう?解決法の一つにDirectoryに関数を渡しちゃうっていうのがあるようです。
fun Directory( filesState: MutableState<List<String>>, runFile: (String)->Unit, modifier: Modifier = Modifier ) { val files by filesState ... Button( onClick = { runFile(file) } ) { Text("Do!") } ... }
あっ、なんか見にくいソースになっちゃったかな?小括弧と中括弧の対がわかりやすく書く方法を後で考えよう。とにかく、関数を渡して、中でそれを実行するっていう関数型言語っぽいことをしてみました。ラムダ式とか匿名関数とか一時期結構もてはやされて、今はいろんなところで使えるようになってますね。JavaScript畑の人なんかずっとおなじみだったんじゃないでしょうか?今回、ファイル名を渡して、返り値がない関数を渡すのですが、型の定義は (String)->Unit となるようです。voidじゃなくてUnitなんだ、へ~。
setContent { ... Directory(filesState, ::runFile) ... }
setContentの中でDirectoryにrunFileを渡してあげれば完了です。::演算子の前のMainActivityは省略できるのか、へ~。
これで望みの機能が全部実装できました。結構楽しかったのでComposableな部品をもっと作ってみたいかも。