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

ReactエンジニアがReact Nativeを使ってみた

はじめに もともとReactを使用してweb開発をしていましたが、React Nativeを使用したモバイル開発に関わることになりました。Reactを使っていた人間がReact Nativeを勉強してみた所感を書きたいと思います。本記事はReact Native公式ドキュメント[1]を参考に書いていきます。 前提 Reactが使える TypeScriptが使える モバイルは初めて 環境構築と簡単な動作確認が知りたい人は前回の記事[1]で行っているのでそちらを参照してください。 React Nativeの必要性 現在モバイル端末での使用OSはAndroidOSとiOSで二極化しており、なおかつそれぞれでの使用プログラミング言語も異なります。昔はiOSではObjective-C、AndroidではJavaが使用されていました。しかし、最近ではiOSではSwift、AndroidではKotlinという言語が使われており、2つのOS合わせると最大4つのコードが存在する可能性があるわけです。もちろんこれは開発効率の観点から見て良くなく、これを解決するためにReact Nativeなどのクロスプラットフォームで開発出来るフレームワークが開発されてきたわけです。 React Nativeでないとだめなのか 近年よく比べられるフレームワークとしてFlutterがあります。よくある議論として「Flutter vs React Native」のようなものがありますが、開発コストという点で見ればReact Nativeの方がFlutterよりも明らかに効率的です。FlutterではDartという言語を新しく覚える必要があり、学習コストは高いです。それに対してReact NativeはReactの知識を利用して開発出来るので、学習コストは比較的低いです。パフォーマンス的にはFlutterの方が良いのかもしれませんが、そこまでパフォーマンスを求めないのであれば手軽に作成できるReact Nativeの方が良いでしょう。ただそのまま使えるわけではなく、多少は覚えることがあるので注意です。 ここからはReact Nativeの概念について話していきたいと思います。 React Nativeのコンポーネント React Native公式ではコンポーネントを何種類かに分けて呼んでいます。 Native Components それぞれのOSでのViewを担当する部分がバックアップしているコンポーネント。 Core Components React Nativeですぐに使えるNative Componentsのセットコンポーネント。 community-contributed components React Nativeコミュニティが開発する各OS独自のNative Component。 各OSの特定の部分に対応するものがReact Nativeコンポーネントであり、それをまとめたものがCore Componentsであると。恐らく僕たちが使うのは基本的にCore Componentsになるということでしょう。それで足りない場合はcommunity-contributed componentsなどにないか探してみるというような感じなのでしょうか。 公式のベン図的な画像を載せておきます。 [1]より引用 あくまでReact Native ComponentsはReact Componentsの部分集合であるということなので、この図からもReact Componentsを理解していれば問題なさそうということが分かりますね。 React Nativeの文法 さてここからが本題です。ReactにはないReact Native特有の部分を見ていきたいと思います。 App.tsxを理解する まずはexpo initコマンドで作成されたテンプレートのApp.tsxの解読をしていきましょう。文章はHello,Worldに変更してあります。 App.tsx import { StatusBar } from "expo-status-bar"; import React from "react"; import { StyleSheet, Text, View } from "react-native"; export default function App() { return ( <View style={styles.container}> <Text>Hello,World</Text> <StatusBar style="auto" /> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#fff", alignItems: "center", justifyContent: "center", }, }); まず、ReactをimportしているようですがこれはReactと違って必要でした。内部的に使っているんでしょうか。StatusBarというやつはなんだろうと調べると、モバイル上部に表示される電源残量や電波状況を表すアイコンが並んでいる部分をステータスバーというようです。ViewとTextはReact Nativeのコアコンポーネントで、詳しくは後ほど説明します。Viewに対してstyleを適用しているようですが、stylesというオブジェクトのプロパティとしてcontainerを設定し、スタイルを記述しています。スタイルについても後ほど詳しく記述しますが、ここではキャメルケースでの書き方でないといけないようで、Reactでのインラインスタイルによるスタイリングと同じですね。 ざっと見ていきましたが、大枠はReactと変わらないようなので、あとは各コンポーネントを覚えていくだけかなと思います。 コンポーネントのprops React Nativeのリファレンスを見ると、propsがiOSとAndroidOSで異なる物が多いようです。また、propsの数自体相当多く、これを全て覚えるのはかなり大変だと思います。この記事では各OS限定のpropsは基本的に扱わず、使いそうなpropsがあれば紹介するという感じで紹介していきますので、詳しく知りたい方は公式のリファレンス[3]を参考にしてください。 View React Nativeにおける一番基本的なコンポーネントです。divタグのようなものだと思って貰えばいいと思います(厳密には少し違う)。 SafeAreaView(iOS) iOSデバイスではノッチや角丸などの物理な違いによって安全にレンダリング出来る範囲が異なります。このコンポーネントでは安全な範囲内でコンテンツをレンダリングすることが出来ます。 Text 文字を書く場合は基本的にこれを使います。Viewに直接書いても反映されません。 ネスト出来る AndroidとiOSでは文字列の範囲に特定の書式をつけることが出来、この実現のためにTextはネストすることが出来ます。ネストしたテキストだけをスタイリングすることで、任意の範囲に書式をつけることが出来ます。 ・レイアウトが特殊 また、Textはレイアウトに関して特殊です。Test内の要素は長方形ではなくなる場合があり、行の終わりを見て折り返します。 ・スタイルの継承が制限されている テキストのスタイルはTextによってしか継承されません。つまりViewでテキストに関するスタイルをしても繁栄されません。特定のスタイルのTextを使いまわしたい場合は既存のTextを拡張したコンポーネントを定義することが公式では進められています。Text内では継承されるため、ネストしたTextでは親のスタイルが適用されます。 Image 画像を利用する際に使います。propsのsourceに参照を渡すことで画像が表示できます。下記に公式の例を載せます。 import React from 'react'; import { View, Image, StyleSheet } from 'react-native'; const styles = StyleSheet.create({ container: { paddingTop: 50, }, stretch: { width: 50, height: 200, resizeMode: 'stretch', }, }); const DisplayAnImageWithStyle = () => { return ( <View style={styles.container}> <Image style={styles.stretch} source={require('@expo/snack-static/react-native-logo.png')} /> </View> ); } export default DisplayAnImageWithStyle; [3]より引用。 TextInput 入力で使用します。全部説明すると長くなるので例を下記に示します。 import React from "react"; import { SafeAreaView, StyleSheet, TextInput } from "react-native"; const UselessTextInput = () => { const [text, onChangeText] = React.useState("Useless Text"); const [number, onChangeNumber] = React.useState(null); return ( <SafeAreaView> <TextInput style={styles.input} onChangeText={onChangeText} value={text} /> <TextInput style={styles.input} onChangeText={onChangeNumber} value={number} placeholder="useless placeholder" keyboardType="numeric" /> </SafeAreaView> ); }; const styles = StyleSheet.create({ input: { height: 40, margin: 12, borderWidth: 1, padding: 10, }, }); export default UselessTextInput; [3]より引用 一番ある使い方はonChangeTextにstateのセッターを入れておいて、入力を反映しつつstateで保持みたいな感じだと思います。気になるのはTypeScriptで使う際にnumberとstringどうやって分けているんだろうというとこですね。時間のある時に検証したら更新します。 ・下にボーダーがある TextInputはデフォルトでビューの下部にボーダーを持ちます。これはシステムが提供する背景画像によってパディングが設定されており、変更することは出来ません。この問題を回避するには、高さを明示的に設定しないようにして、システムが適切な位置にボーダーを表示するようにするか、underlineColorAndroidをtransparentに設定してボーダーを表示しないようにすることが必要なようです。詳しくは公式リファレンスを見てください。 ScrollView リファレンスを直訳したんですが、少し意味がわからないところがあったので分かりにくいかもしれません。 スクロールしたい場合に使います。React NativeではViewを使って表示する場合、表示画面領域より大きいものをレンダリングしようとしても自動的にスクロールするようにはならないようです。スクロールしないことは少ないと思うので基本的にはこれを使うことになるんでしょうか。 ・全ての親Viewが高さ制限されている必要がある リファレンスではboundedと書かれていたのですが、この訳で合っているのかあやしいです。高さを制限する方法は2つありViewの高さを直接設定すること(非推奨)と全ての親Viewに高さを設定することです。{flex:1}を忘れるとエラーが出るとリファレンスには書いてあるのですが、よく分かりません。下記に例を載せておきます。 import React from 'react'; import { StyleSheet, Text, SafeAreaView, ScrollView, StatusBar } from 'react-native'; const App = () => { return ( <SafeAreaView style={styles.container}> <ScrollView style={styles.scrollView}> <Text style={styles.text}> Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. </Text> </ScrollView> </SafeAreaView> ); } const styles = StyleSheet.create({ container: { flex: 1, paddingTop: StatusBar.currentHeight, }, scrollView: { backgroundColor: 'pink', marginHorizontal: 20, }, text: { fontSize: 42, }, }); export default App [3]より引用。 showsVerticalScrollIndicator このpropsをfalseにすることでスクロールバーを表示しないように出来ます。 horizontal このpropsをtrueにすることで横スクロールにも対応できます。 FlatlListとの違い ScrollViewでは全ての子コンポーネントを一度にレンダリングするため、パフォーマンス上の問題があります。FlatListではアイテムが表示されようとしているときに遅延的にレンダリングし、画面外に大きくスクロールするアイテムを削除することでメモリや処理時間の節約が出来ます。FlatListはほかにもアイテム間の距離、複数列、無限スクロールローディングなどの機能をサポートしているので便利です。 ここまで書いていて思いましたが、もしかして割とScrollViewって要らない子?状況に応じて使い分けるんですかね。 StyleSheet ここまででも使用してきていますが、cssのスタイルを使用するために利用しています。特に言うことはないのですが、React NativeはTailwind CSSとは相性が良くなさそうだなーと思います。調べた感じライブラリなどを使えばいけるようですが、vscodeのインテリセンスなどの関係上どうなのかなーという感じです。 逆にCSS in JSは特に変化なく使えそうだなと思います。webとほとんど変わらず記述出来るので、開発コストを削減するという観点から見たら、React NativeではCSS in JSを用いるのがいいんじゃないかなと思います。僕はstyled-componentsを用いて書いているので苦労することはなさそうです。 Button ボタンに相当するコンポーネントです。見たほうが早いと思うので下記に例を載せます。 <Button title="Press me" disabled onPress={() => Alert.alert('Cannot press this one')} /> [3]より引用。 propsとしてonPressとtitleは必須のようです。titleでボタンの表示を決めるようですが、childrenでは無理ということでしょうか。Alert.alertでwindow.alertのようなことが出来るようです。またこの例ではdisabledにしているので利用できないような見た目になります。動的に決定したい場合はdisabledのところにboolean型の変数を入れれば可能でしょう。 Switch このSwitchはswitch文のことではなく、切り替えるためのボタンのことです(名前あるのかな)。リファレンスのダークモード切り替えの部分によくあるやつです。下記に例を示します。 <Switch trackColor={{ false: "#767577", true: "#81b0ff" }} thumbColor={isEnabled ? "#f5dd4b" : "#f4f3f4"} ios_backgroundColor="#3e3e3e" onValueChange={toggleSwitch} value={isEnabled} /> [3]より引用 タップすることでboolean型変数がトグルし、valueの値によって色々変わるという感じです。 TouchableOpacity 押されるとラップされたViewの不透明度が減少し、薄暗くなります。 <TouchableOpacity style={styles.button} onPress={onPress} > <Text>Press Here</Text> </TouchableOpacity> [3]より引用 FlatList 先程も説明しましたが、リスト表示をする際に利用します。 data 表示したい配列データ。 renderItem dataを使ってどのようなものを表示するかを決める関数 extraData 追加データ。下の例では再レンダリングするためにstateを渡しています。 keyExtractor デフォルトのkeyプロパティの代わりに使用するidの指定。 ・コンテンツがレンダリング領域の外にいったら内部状態は保持されない FlatListの仕様上レンダリングの外のコンテンツの内部状態は保存されません。そのためstateの更新時とレンダリングについて気をつける必要があります ・コンテンツは画面外で非同期にレンダリングされる この特徴によりコンテンツ外のレンダリングよりも早くスクロールすると、一時的に空白のコンテンツが表示される可能性があります。 import React, { useState } from "react"; import { FlatList, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity } from "react-native"; const DATA = [ { id: "bd7acbea-c1b1-46c2-aed5-3ad53abb28ba", title: "First Item", }, { id: "3ac68afc-c605-48d3-a4f8-fbd91aa97f63", title: "Second Item", }, { id: "58694a0f-3da1-471f-bd96-145571e29d72", title: "Third Item", }, ]; const Item = ({ item, onPress, backgroundColor, textColor }) => ( <TouchableOpacity onPress={onPress} style={[styles.item, backgroundColor]}> <Text style={[styles.title, textColor]}>{item.title}</Text> </TouchableOpacity> ); const App = () => { const [selectedId, setSelectedId] = useState(null); const renderItem = ({ item }) => { const backgroundColor = item.id === selectedId ? "#6e3b6e" : "#f9c2ff"; const color = item.id === selectedId ? 'white' : 'black'; return ( <Item item={item} onPress={() => setSelectedId(item.id)} backgroundColor={{ backgroundColor }} textColor={{ color }} /> ); }; return ( <SafeAreaView style={styles.container}> <FlatList data={DATA} renderItem={renderItem} keyExtractor={(item) => item.id} extraData={selectedId} /> </SafeAreaView> ); }; ・・・ [3]より引用 map関数によるレンダリングの代わりなのかなーとおもっています。 Section List FLatListに見出しがついたような感じのものです。ほとんど同じなので使い方は下のサンプルプログラムを見てください。 import React from "react"; import { StyleSheet, Text, View, SafeAreaView, SectionList, StatusBar } from "react-native"; const DATA = [ { title: "Main dishes", data: ["Pizza", "Burger", "Risotto"] }, { title: "Sides", data: ["French Fries", "Onion Rings", "Fried Shrimps"] }, { title: "Drinks", data: ["Water", "Coke", "Beer"] }, { title: "Desserts", data: ["Cheese Cake", "Ice Cream"] } ]; const Item = ({ title }) => ( <View style={styles.item}> <Text style={styles.title}>{title}</Text> </View> ); const App = () => ( <SafeAreaView style={styles.container}> <SectionList sections={DATA} keyExtractor={(item, index) => item + index} renderItem={({ item }) => <Item title={item} />} renderSectionHeader={({ section: { title } }) => ( <Text style={styles.header}>{title}</Text> )} /> </SafeAreaView> ); ・・・ [3]より引用。 おわりに ざっと概念と文法を見てきましたが、理解すれば簡単に使えそうです。ただ、想像していたよりは覚えることがあったのであとは実際に開発してから使用感を確かめていきたいと思います。 参考文献 [1]:Introduction [2]:Ubuntu20.04LTSでReact NativeとExpoを試す [3]:Core Components and APIs
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

BottomNavigationでアイコンを再タップして子画面から親画面に戻すテクニック

概要 この記事では、Bottom navigationで現在選択されている行先のアイコンを再度タップすることにより、その行先のトップの画面(ここでは親画面と呼びます)へと戻る処理を実装します。 これにより、ユーザーは複数の階層を下った画面からでも1タップで親画面へ戻ることができるようになります。 また、親画面で再タップした時に(親画面にあるRecyclerViewをトップまでスクロールするなどの)特定のアクションを起こせるような実装も同時に行います。 環境 Kotlin 1.5.31 Androidx.Navigation 2.3.5 テクニック アイテムを再選択したことの検知 BottomNavigationView#setOnNavigationItemReselectedListener(listener) を使います。 ui/MainActivity.kt binding.bottomNavigation.setOnNavigationItemReselectedListener { menuItem -> // ... } これによって、BottomNavigationのアイテムを再選択したときにそのアイテムを引数に取るリスナーが発火します。 子画面から親画面へと戻らせる 子画面に一々親画面へ戻る処理を書くのは追加漏れや疎結合性などの観点から避けたいです。なので、子画面から親画面に戻る処理をボトムナビゲーションを配置しているActivity側1で記述します。 ui/MainAcitivity.kt binding.bottomNavigation.setOnNavigationItemReselectedListener { menuItem -> // メニューアイテムのidと対応する親画面のidのmap val menuItemIdToFragmentIdMap = mapOf( R.id.menu_home to R.id.homeFragment, // ... ) val rootDestinationIds = menuItemIdToFragmentIdMap.values val currentId = navController.currentDestination?.id ?: return@setOnItemReselectedListener val rootId = menuItemIdToFragmentIdMap[menuItem.itemId] ?: return@setOnItemReselectedListener if (currentId in rootDestinationIds) { // 親画面だった時の処理 } else { // 子画面だった時の処理 navController.popBackStack(rootId, false) } } 予め、menuItemIdToFragmentIdMapにボトムナビゲーションのメニューアイテムとそれに対応した親Fragmentのidのマップを作っておき、現在いるFragmentのidが親Fragmentのセットに含まれるかどうかで子画面かどうかを判別します。もし子画面であれば、そのidを元に親FragmentへNavController#popBackStack(destinationId, inclusive)でポップします。これで、Fragmentの再生成を行わず状態を保持したまま親画面へと戻す処理が出来ました。 また、親のFragmentのセットをナビゲーショングラフから取得する別の方法として以下のようなものもあります。 2 val rootDestinationIds = navController.graph .map { if (it is NavGraph) it.startDestination else it.id } // トップのナビゲーショングラフがグラフしか持たないことを想定するなら // .mapNotNull { (it as? NavGraph)?.startDestination } でも良い .toSet() ただし、この方法はボトムナビゲーションのグラフがアイテム毎にその画面のグラフを持たせていることを前提としてそのグラフにある行先を集めているだけなので、ボトムナビゲーションの画面ごとにナビゲーショングラフを別に作っておく必要があります。 親画面での再選択イベントを親画面のViewModelに流す さて、これで子画面から親画面へと戻る処理については実装できましたが、親画面でアイテムを再選択したときの処理を扱うことが出来ていません。親画面での処理をActivity側で行うのは非現実的なので、SharedFlowを使って親画面のViewModelにアイテムの再選択についてのイベントを流し、それを親画面側で受け取ることで対応します。 まず選択した画面を表現する列挙型クラスを作成します。 model/MainBottomNavigationSelectedItem.kt enum class MainBottomNavigationSelectedItem { HOME, DASHBOARD, NOTIFICATIONS; fun isHome(): Boolean = (this == HOME) fun isDashboard(): Boolean = (this == DASHBOARD) fun isNotifications(): Boolean = (this == NOTIFICATIONS) } そして、MainActivity側のViewModelで親画面での再選択イベントを流すためのSharedFlowを作成し、再選択時に発火するようにします。 ui/MainViewModel.kt class MainViewModel : ViewModel() { private val reselectedItemOnRootSource: MutableSharedFlow<MainBottomNavigationSelectedItem> = MutableSharedFlow() val reselectedItemOnRoot: SharedFlow<MainBottomNavigationSelectedItem> = reselectedItemOnRootSource.asSharedFlow() fun reselectBottomNavigationItemOnRoot(@IdRes selectedMenuId: Int) { val reselected = when (selectedMenuId) { // ナビゲーション上での親画面のid R.id.homeFragment -> MainBottomNavigationSelectedItem.HOME R.id.dashboardFragment -> MainBottomNavigationSelectedItem.DASHBOARD R.id.notificationsFragment -> MainBottomNavigationSelectedItem.NOTIFICATIONS else -> return } viewModelScope.launch { reselectedItemOnRootSource.emit(reselected) } } } ui/MainActivity.kt if (currentId in rootDestinationIds) { // 親画面だった時の処理 viewModel.reselectBottomNavigationItemOnRoot(rootId) } else { // 子画面だった時の処理 // ... } 最後に、activityViewModels()でそれぞれの親画面からreselectedItemOnRootを購読することで親画面での再選択時のイベントを扱うことができます。 ui/home/HomeFragment.kt class HomeFragment : Fragment() { // ... private val mainViewModel by activityViewModels<MainViewModel>() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { // ... lifecycleScope.launch { mainViewModel.reselectedItemOnRoot .filter { it.isHome() } // 再選択した画面が自分の画面でない可能性を弾く .collect { // 親画面でアイテムが再選択されたときの処理 binding.recycler.smoothScrollToPosition(0) } } // ... } } デモ 以下のリポジトリにこの記事のデモがあります。 簡単のためにActivityとしているが、実際はMainActivityの下にMainFragmentなどを挟んでいる場合もあると思うので、そういった場合は適宜読み替えてほしい。 ↩ popBackstackする時にidが必要になるので結局のところコード上にidは書かざるを得ないが、その時にはwhen式で書くことになるので、評価する式によっては網羅的なチェックを入れられてコンパイル時に追加漏れを弾けるメリットがある。 ↩
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Kotlin】TextView・AlertDialogにHTMLのタグを適用する

この記事では、以下のようなStringをTextViewにバインドし、TextViewにHTMLタグを適用させていきます。 val link = """ <a href="https://qiita.com/">Qiita</a>を開く """.trimIndent() HtmlCompactの利用 HtmlCompactを利用することでHTMLのタグをAndroidにも適用することができます。 private fun transformHtml(text: String): Spanned { return HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_COMPACT) } 上記のメソッドにHTMLタグを含んだStringを渡し、その戻り値を表示したいTextViewに渡してあげることで適用できます。 override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { binding = MainFragmentBinding.inflate(layoutInflater) binding.message.text = transformHtml(link) binding.message.movementMethod = LinkMovementMethod.getInstance() return binding.root } private fun transformHtml(text: String): Spanned { return HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_COMPACT) } また、リンクのタップを検知するにはmovementMethodをつける必要があります。 binding.message.movementMethod = LinkMovementMethod.getInstance() おまけ テキストリンクの色を変えたい時はandroid:textColorLinkを設定することで任意の色に変更できます。 android:textColorLink="@color/black" ダイアログのテキストに適用する アラートダイアログにも適用することができます。 val dialog = AlertDialog.Builder(requireContext()) .setTitle("ダイアログ") .setMessage(transformHtml(link)) .setPositiveButton(android.R.string.ok) .create() dialog.show() dialog.findViewById<TextView>(android.R.id.message)?.movementMethod = LinkMovementMethod.getInstance() ダイアログに適用させる場合も基本はTextViewと同様ですが1点注意が必要です。 movementMethodを適用する際に、リソースIDの指定が必要になります。しかし、ダイアログでfindViewByIdを利用する場合、show()の後に呼ばないとリソースIDが参照できないため上記のような書き方をしています。(もっとスマートにかける方法ありましたら教えていただきたいです ) 参考文献 // Make the textview clickable. Must be called after show()
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む