20210908のAndroidに関する記事は3件です。

【Android】Jetpack ComposeでGithubのリポジトリ検索アプリを作ってみる

概要 Androiderのみなさま、Jetpack Composeはもう使いましたか? 今年(2021年)の7月末にようやく安定版(1.0)が出て、界隈でいっそう盛り上がりを増していますね。 今回はComposeを使ってGithubリポジトリを検索するアプリを作成してみました。 できたもの アプリを開くと検索画面を表示し、最初は「Jetpack Compose」で検索します。 リポジトリをタップするとWebViewで該当リポジトリを表示します。 デザインはMaterial Designからグレー色を適当に散りばめてますが、少し配色ミスったかも? 構成 Clean Architecture DIにHiltを使用 View側はComposeとNavigationを使用 画像ライブラリにCoilを使用 Coroutineをベースにしているため動作が軽いです Composeにも対応しています! ポイント ここでは特徴的な実装について紹介します。 Composeの個々のコンポーネントの説明は省きますが、後追いで追加するかも。 ViewModelのデータを用いてComposeを更新する ComposeではState<T>オブジェクトを用いて再描画を行います。 Coroutine Flow用の拡張関数であるcollectAsStateを使うと、ViewModelが公開したデータをそのままStateオブジェクトに変換することができるため、これをCompose側で使うことで、ViewModel側でデータを更新する度に再描画をしてくれます。 また拡張関数は以下のものが用意されています。 - StateFlow用の拡張関数であるcollectAsState - LiveData用のobserveAsState (runtime-livedataのライブラリが必要) - Rx用のsubscribeAsState (runtime-rxjava2のライブラリが必要) val uiState by viewModel.uiState.collectAsState() HomeScreen( uiState = uiState, ~略~ ) Bundleみたいに次のScreenにdata classを渡す Bundleで次のActivityに値を渡すように、data classを渡す方法ですが、 Action側でdata classをMoshiなどを使ってjson化し、Stringとして渡す NavHost側で受け取ったStringを元に戻してからScreenに渡す で実現できます。なお、URLなどが含まれる場合のために、クエリパラメータ方式でStringを受け取っています。 class MainActions(navController: NavHostController) { val navigateToRepositoryDetail: (RepositoryEntity) -> Unit = { entity -> val repositoryJson = Moshi.Builder().add(KotlinJsonAdapterFactory()).build().adapter(RepositoryEntity::class.java).toJson(entity) navController.navigate(MainDestinations.REPOSITORY_DETAIL_ROUTE + "/?repository=" + repositoryJson) } } ~~略~~ NavHost( navController = navController, startDestination = startDestination ) { composable( route = "${MainDestinations.REPOSITORY_DETAIL_ROUTE}/?repository={repository}", arguments = listOf(navArgument("repository") { type = NavType.StringType }) ) { backStackEntry -> backStackEntry.arguments?.getString("repository")?.let { repositoryJson -> Moshi.Builder().add(KotlinJsonAdapterFactory()).build().adapter(RepositoryEntity::class.java).fromJson(repositoryJson)?.let { RepositoryDetailScreen(repository = it, onBackPress = actions.upPress) } } } } SearchBarの実装について SearchBarはTopAppBarのtitle部分にTextFieldを突っ込むだけで実装可能です。 内部のtextはrememberで保持することができます。 TopAppBar( title = { // SearchBar Row( verticalAlignment = Alignment.CenterVertically, ) { TextField( modifier = Modifier.weight(weight = 1f), leadingIcon = { Icon( imageVector = Icons.Filled.Search, contentDescription = "Search Icon" ) }, value = text, onValueChange = { text = it }, maxLines = 1, singleLine = true, ) Button(onClick = { onSearch(text) }) { Text(text = "決定") } } }, ) もっと綺麗なSearchBarの実装が見たい方は、airbnbのShowkaseライブラリにいい感じの実装が置いてあります。 ページング処理について GoogleとしてはPagingライブラリを使うのを理想形にしてそうな予感がしますが、執筆時点でpaging-composeライブラリがalpha版ということでしたので、今回は海外兄貴のListStateを使った実装を参考にしました。 実際のコードを見ていただくとわかる通り、RecyclerViewのScrollListenerを使った方法と同じ手法です。実際にやっていることは以下の通りです。 remember と derivedStateOfで、LazyListStateからLoadMoreの判定Stateを作成する LaunchedEffectはLoadMoreの判定Stateの変化を検知して新しいコルーチンを作成する snapshotFlowでStateをFlowに変換して、onLoadMoreを発火させる @Composable fun LazyListState.OnBottomReached( onLoadMore : () -> Unit ) { val shouldLoadMore = remember { derivedStateOf { val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull() ?: return@derivedStateOf true lastVisibleItem.index >= layoutInfo.totalItemsCount - 3 } } LaunchedEffect(shouldLoadMore){ snapshotFlow { shouldLoadMore.value } .collect { if (it) onLoadMore() } } } 所感 ※わたしはFlutter民でもあるので、Flutterと比べた感想多めです? 一連の実装を見てみると、Presentation層はFlutterに近い印象を受けました。ViewModelからStateFlowで公開→collectAsStateで更新検知する部分なんかは、Flutterのriverpod + state_notifierを使用した構成とかなり似ていますね。 また、今回実装していく中で、もう少しプレビュー表示が早くなってくれるといいなと感じました。 FlutterはHot Reloadを採用していることもあり、実データ+実機で素早く動作確認ができるという点が非常に安心感があるのですが、Composeは従来のxml表示と同様、プレビュー設定をした上でビルドしないとレイアウトが表示されません。ビルドがもたつくと確認まで時間がかかります。 この辺は技術的な課題もあるとは思いますが、テキパキ修正できるようにしてくれると助かるな〜という気持ちがありました。 とはいえ、Googleは今後Jetpack Composeに力を入れていきそうなので、その辺りの改善も含め注目していきたいですね。 参考文献 Jetpack Compose公式 Jetpack Compose公式サンプル 【Jetpack Compose】Githubリポジトリを一覧表示するサンプルアプリ作った Infinite List / Paged List in Jetpack Compose Jetpack Compose Navigation and Passing Data | NavHost SearchView Toolbar with Jetpack Compose
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Flutter】showModalBottomSheetでSafeAreaが効かない時の対処法

概要 FlutterでAndroidアプリのUI実装をする際、isScrollControlledをtrueにしたModalBottomSheetが画面上部の網掛け部分にかかってしまう問題の解消法をご紹介いたします。 使用するWidget 親Widget(お問い合わせフォーム) - main.dart 子Widget(メッセージ送信フォーム)- child.dart 画面上部の網掛け部分にViewが被ってしまっていますね・・。 AppBarが無いViewではSafeAreaを使うことで、画面上部に被らないように実装できるはずなのですが・・ childView.dart Widget build(BuildContext context) { return Scaffold( body: SafeArea( //SafeAreaを使えば画面上部に被るのを防止できるはず・・ child: Container(...), ) ); } 残念ながら変わりません・・。 解決策 main.dart // --省略 child: ElevatedButton( child: Text("お問い合わせをする"), onPressed: () { BuildContext mainContext = context; //これを追加 showModalBottomSheet( context: context, isScrollControlled: true, builder: (BuildContext context) { return parentWidget( mainContext //子Widgetに渡す ); }); }, ), childView.dart Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: Container( margin: EdgeInsets.all(20), padding: EdgeInsets.only( // top: MediaQuery.of(widget.context).padding.top, //親Widgetから渡されたBuildContextをpaddingで指定 ), child: Column(//..省略), ), ), ); } 無事いい感じに表示されました。 問題点 親Widgetがmain.dartじゃない場合、子WidgetにBuildContextを渡してpaddingに指定しても0.0として扱われ、正しく対処できない。 → main.dartでMediaQueryを使って画面上部のpaddingを取得し、子Widgetまで渡してpaddingで指定すれば良い? main.dart double height = MediaQuery.of(context).padding.top; 端末ごとの画面上部の高さはこのようにして取得できます。 良い感じの解決策は現在調査中です・・。 参考
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Flutter】カウンターアプリに書かれたコメントを解説する

flutter create で新しく Flutter プロジェクトを作ると自動生成されるのが「カウンターアプリ」であることは、Flutter を触ったことがある方であればピンとくるのではないかと思います。 Flutter アプリ開発者にとっての初めの一歩とも言うべきカウンターアプリですが、その main.dart に書かれているコメントを読んだことがあるでしょうか。私は毎回全置換で消しています。 ただ改めて考えてみると、このコメントも Flutter フレームワークの開発チームが Flutter アプリ開発者のためにわざわざ書いてくれたもの だと思いますので、この記事ではそのコメント全てを翻訳しながらしっかりと内容を確認してみたいと思います。 また、初学者向けの簡単な解説も交えつつ書いていければと思いますので、何かの役に立てれば嬉しいです。 ではどうぞ。 class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { まずは MyApp クラスの宣言の下、8 行目に記載された内容です。 (意訳) この Widget はアプリケーションの根っこです。 Flutter では、 Widget をツリー構造で下へ下へとつなげていくことで UI を構築します。カウンターアプリにおける MyApp という Widget はそのツリー構造のまさに開始地点(根っこ)であるということがここに記載されています。 @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( // This is the theme of your application. // // Try running your application with "flutter run". You'll see the // application has a blue toolbar. Then, without quitting the app, try // changing the primarySwatch below to Colors.green and then invoke // "hot reload" (press "r" in the console where you ran "flutter run", // or simply save your changes to "hot reload" in a Flutter IDE). // Notice that the counter didn't reset back to zero; the application // is not restarted. primarySwatch: Colors.blue, ), home: MyHomePage(title: 'Flutter Demo Home Page'), ); } MyApp の build() メソッドの中、 14 行目からのコメントには以下のようなことが書かれています。 (意訳) これはアプリケーションのテーマです。アプリケーションを "flutter run" コマンドで実行してみてください。青いツールバーが表示されるはずです。では、アプリは起動したまま、下に書かれた primarySwatch の値を Colors.green に変更して "ホットリロード" を実行してみてください(コンソールから "flutter run" で実行している場合は "r" キーを押します。 Flutter に対応した IDE で実行している場合は単純にファイルを保存すればホットリロードが実行されます)。カウンターがゼロに戻ることなく、つまりアプリがリスタートすることはありません。 ここでは ThemeData がアプリ全体のテーマ(色など)を保持するオブジェクトであることと "ホットリロード" の説明がされています。 ホットリロードはご存知の通り、 Flutter の目玉とも言える機能です。ここで指示された通りに primarySwatch の値を Colors.blue から Colors.green に変えてファイルを保存するだけで、アプリを再起動することなく UI が変化することが体験できるようになっています。 Flutter フレームワークの開発チームとしても、まず初めにこのホットリロードを体験してほしい、ということなのだろうと推測できます。 class MyHomePage extends StatefulWidget { MyHomePage({Key? key, required this.title}) : super(key: key); // This widget is the home page of your application. It is stateful, meaning // that it has a State object (defined below) that contains fields that affect // how it looks. // This class is the configuration for the state. It holds the values (in this // case the title) provided by the parent (in this case the App widget) and // used by the build method of the State. Fields in a Widget subclass are // always marked "final". final String title; @override _MyHomePageState createState() => _MyHomePageState(); } 次はさらに下に下がったところ、 33 行目から始まるコメントです。 (訳) この Widget はアプリケーションのホームページです。これは stateful、つまり State という(この下に定義された)オブジェクトを持っており、State には見た目を変化させるためのフィールドが定義されています。 この MyHomePage という Widget クラスは、その State の設定値を保持するクラスです。このクラスは親(つまり MyApp) から受け取った値(ここでは title)を保持していて、この title は State の build() メソッドで使われています。 Widget のサブクラスのフィールドは常に "final" でなければなりません。 ここは Flutter で動的に変化する UI を構築するためのひとつの手段である StatefulWidget の説明がされています。 StatefulWidget は Widget が設定値を保持し、 State の build() でそれを使うことで、例えばここでは親である MyHomePage から受け取ったタイトル文字列が AppBar に表示されるようになっています。 Widget に定義するフィールドは常に final でなければならないという点も重要です。 Widget は「不変な」(immutable)クラスであるため、保持するフィールドの値を変えることはできません。 Widget は「不変な値(設定値)を保持するオブジェクト」としての役割に徹する ことで Flutter フレームワークは Widget オブジェクトの(オブジェクト生成後の動的な)変化を気にする必要がなくなり、それをスムーズな UI 描画のための最適化に利用しています。 一方で、ユーザーの操作などによってフィールドの値を動的に変化させたい場合はその値を State に保持 させます。State は Widget とは違い、 一度生成したオブジェクトをなるべく使い回す ために設計されたクラスです。アプリの操作に合わせて UI を変化させるためのデータもこの State オブジェクトが保持するのが Flutter における基本的なやり方です。1 さて、先に進みます。 class _MyHomePageState extends State<MyHomePage> { int _counter = 0; void _incrementCounter() { setState(() { // This call to setState tells the Flutter framework that something has // changed in this State, which causes it to rerun the build method below // so that the display can reflect the updated values. If we changed // _counter without calling setState(), then the build method would not be // called again, and so nothing would appear to happen. _counter++; }); } これは先ほど出てきた State クラスのコードです。 StatefulWidget には必ずペアになる State が存在し、 MyHomePage という Widget のペアになるのがこの _MyHomePageState である、というわけです。 さて、 53 行目から始まるコメントを確認します。 (意訳) この setState を呼び出すことで、 Flutter フレームワークに対して State が保持する何らかの値が変化したことを伝えます。これにより、下に定義した build() メソッドが再度呼び出され、変更後の値が画面上の表示内容に反映されます。もし _counter の値を setState() を呼び出すことなく変化させた場合、 build() メソッドが呼び出されずに UI も何も変化しません。 State が保持する値を変更する場合、必ず setState() をセットで呼び出さなければならない(正確には、 値を変化させる関数を setState() メソッドの引数に渡す)ことが記載されています。 Flutter フレームワークは State のフィールドの値が変化したこと自体を検知することはできません。 setState() が呼ばれることによって初めて「値が変わって UI の更新が必要になった」と判断し、 build() をもう一度呼び出すことで画面を更新します。 さて、次はその build() メソッドの中のコメント、 64 行目です。 @override Widget build(BuildContext context) { // This method is rerun every time setState is called, for instance as done // by the _incrementCounter method above. // // The Flutter framework has been optimized to make rerunning build methods // fast, so that you can just rebuild anything that needs updating rather // than having to individually change instances of widgets. return Scaffold( appBar: AppBar( (意訳) このメソッドは setState が呼び出されるたびに実行され直します。例えば、上に定義された _incrementCounter メソッドの実行が完了された後などです。 フラッターフレームワークはこの build メソッドが高速に呼び出されるように最適化されています。つまり、Flutter アプリの開発者は UI の更新のために個別の Widget に対して何かの更新操作を行うのではなく、必要なタイミングでこの build メソッドを呼び出して全体をリビルドできるようになっています。 これは Kotlin や Swift といったいわゆる「ネイティブの」アプリ開発を経験した方にとっては見慣れない考え方なのではないかと思います。 Flutter では、一度生成した「テキスト」や「画像」などを表すオブジェクトに対して「表示内容を変更する」ようなメソッドを呼び出すことはしません。 替わりに、 build() メソッドを呼び出し直して Widget オブジェクトそのものを全て取り替える ことで画面を更新する作りになっています。 その際、 Widget の構築に利用しているフィールドの値が変わっていれば build() メソッドのロジックの実装に従って生成される Widget オブジェクトも変化し、結果として UI が変化する 、という考え方です。 ここで勘違いしてはいけないのは、 Widget オブジェクト自身はレイアウト計算や UI の描画を担当するオブジェクトではないということです。先ほどのコメントで書いてあった通り、Widget は「設定値」を保持するだけのオブジェクトですので、画面全体の Widget オブジェクトを再生成したからといって画面全体が再描画されるわけではありません。ここが Kotlin や Swift の View というものとは違う点です。 再生成された Widget と古い Widget を比較し、 UI の変更が必要な最低限の部分だけを再描画する 、という最適化を Flutter フレームワークは行っています。つまり、アプリ開発者はとにかく新しい値を保持した Widget オブジェクトを生成すれば、あとは必要なものだけを Flutter フレームワークが判断して使ってくれる、という考え方で build() メソッドを実装します。 次は 72 行目です。 return Scaffold( appBar: AppBar( // Here we take the value from the MyHomePage object that was created by // the App.build method, and use it to set our appbar title. title: Text(widget.title) (意訳) ここで、 MyApp の build() メソッドの中で生成され、 MyHomePage オブジェクトを経由して受け取った文字列を使って AppBar のタイトルをセットしています。 Widget が保持する値を State で利用する場合は、この例のように widget.xxx でアクセス可能です。ここでは、 MyApp から受け取った "Flutter Demo Home Page" という文字列が widget.title に格納されていて、それが AppBar の中に表示されるタイトルとして利用されるわけです。 body: Center( // Center is a layout widget. It takes a single child and positions it // in the middle of the parent. (意訳) Center はレイアウトのための Widget です。子となる Widget をひとつだけ受け取り、親に対して中央寄せになるように配置します。 Flutter では「テキスト」や「画像」といった表示物だけでなく、それを配置するためのレイアウトの設定を保持するのも Widget の役割です。 Center はそのようなレイアウト用の Widget のひとつであることが説明されています。 child: Column( // Column is also a layout widget. It takes a list of children and // arranges them vertically. By default, it sizes itself to fit its // children horizontally, and tries to be as tall as its parent. // // Invoke "debug painting" (press "p" in the console, choose the // "Toggle Debug Paint" action from the Flutter Inspector in Android // Studio, or the "Toggle Debug Paint" command in Visual Studio Code) // to see the wireframe for each widget. // // Column has various properties to control how it sizes itself and // how it positions its children. Here we use mainAxisAlignment to // center the children vertically; the main axis here is the vertical // axis because Columns are vertical (the cross axis would be // horizontal). mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ (意訳) Column もレイアウトのための Widget です。Column は子のリストを受け取り、それを縦に並べます。デフォルトでは、子の幅がちょうど収まるだけの幅と、親と同じだけの高さを確保します。 "debug painting" を実行することで(コンソールでは "p" キーを押します。Android Studio の場合は Flutter インスペクタの "Toggle Debug Paint" アクションを選択してください。Visual Studio の場合は"Toggle Debug Paint" コマンドを選択します) それぞれの子Widget のワイヤーフレームを確認できます。 Column にはどのように自身のサイズを決定し、どのように子を配置するかを設定する多くのプロパティが用意されています。ここでは、 mainAxisAlignment を指定することで子を縦方向で中心寄せで配置しています。なお、 "main axis" とは、 Column においては縦方向の軸を意味します。(また、 "cross axis" は横方向です) 先ほどの Center は子を1つだけ受け取る Widget だったのに対し、 Column は複数の子 Widget を受け取る Widget で、それを縦に並べる役割を持っています。 main axis と cross axis の用語は UI を構築する上で頻繁に出てくるので覚えておくとよいでしょう。 children を縦に並べる Column においては、 main が縦、 cross が横方向となりますが、 children を横に並べる Row においては、 main は横、 cross が縦方向となります。 ここでは、Flutter の "Debug Painting" 機能についても説明されています。実際にコメントの指示通りに実行してみると、以下のように子要素の範囲や Column 自身の幅、高さが画面上に可視化され、これを元にレイアウトを調整できるようになっているというわけです。 さて、次で最後のコメントです。 floatingActionButton: FloatingActionButton( onPressed: _incrementCounter, tooltip: 'Increment', child: Icon(Icons.add), ), // This trailing comma makes auto-formatting nicer for build methods (意訳) この末尾のカンマは build メソッドの自動フォーマットを良い感じにするためのものです。 Flutter は Widget をツリー構造で構築する仕組みになっている都合上、 build() メソッドは Widget の入れ子になることが多いです。つまり、ソースコード的にネストがどんどん深くなっていきます。 そのネストをきれいな階段上にフォーマットするための目印として、標準で用意されているコードフォーマッタはカッコの後ろのカンマを手がかりにします。そのため、基本的にはきれいにフォーマットするためにカンマはつけた方が良い旨が書かれています。 なお、このネストが深くなることに対して違和感を感じるアプリ開発者も多いようですが、考えようによっては Web ページの HTML も Android ネイティブの XML も、 UI を構築するための記述はネストすることが多いですし、また ネストしているからこそ各部分の記述がページの大枠を示すものなのか、それとも細かな一部のパーツを表すものなのかがネストの深さから推測しやすい 、というメリットがあったりします。 このあたりは慣れの問題もあると思いますが、とにかく Flutter ではネストするのが基本で、それをきれいに階段上に整形するためにカンマをつけるのは重要である、ということです。 以上です。 自動生成されたカウンターアプリのコードは Flutter の初学者も読む関係でここに書かれたコメントは Flutter の特徴を手っ取り早く掴むためにかなり厳選された内容になっています。 特にわれわれ日本人にとっては無視されがちな英語のコメントではあると思いますが、一方でこのコメントに従って手を動かしてみることで、効率よく Flutter の仕組みをざっと把握し、また用意されたツールや仕組みを体験できる内容になっていることがわかったのではないかと思います。 すでに何かしらの記事やドキュメントを見ながら Flutter を触っている方も多いと思いますが、一度スタート地点に戻ってここに書かれた内容を追ってみてはいかがでしょうか。 他のやり方もいろいろと存在し、それは「状態管理」という名称で説明されています。 ↩
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む