localeに繁體中文を設定してもvalues-zhが選択されない
今回もマニアックなトピックです。
タイトルの通り。ちょっと困ったので調べて見た。
何が起こったか
下記どちらかに当てはまる場合、
- 端末で繁體中文を言語設定で選択している
- アプリ独自のlocale設定で
zh_HK
(中国語-香港)を設定している
res
下の values-zh/strings.xml
が読まれず res/strings.xml
が選択された。
例えば、values/strings.xml
と、
<resources> <string name="test">Test</string> </resources>
values-zh/strings.xml
がある場合、
<resources> <string name="test">測試</string> </resources>
言語設定を繁體中文にしていても測試ではなくTestが表示される。同じ中国語(zh)ならvalues-zh
が優先されると思いきや、そんなことない。
ここでvalues-zh/strings.xml
をvalues-zh-rHK/strings.xml
にすれば測試になってまあ当面困ることはないが釈然としない。
ちなみに確認環境はNexus 6P (OS 7.1.2) 実機。
N以降でおきてるっぽい
そういえばNからLocale周りで変更入ったな?🤔 というのを思い出した。
試しにEmulator (Nexus 5 / API 23) で動かして見たらvalues-zh/strings.xml
が選択されて測試が表示された。
同じEmulator環境でもN系のものはだめ。(Nexus 6P / API 25とNexus 5X / API 23 で確認。)
一瞬Multi localeになった影響でEnglishがlocale listの二番目にきている影響か?と思ってEnglishを削除して見たけどだめ。
よく考えたらそもそもvalues-en
を作ってないテスト環境でも再現したから影響しようがなかった。
Frameworkのコードを読んでみよう
N以降のFrameworkのコードを読んでみる。ResourcesImpl.java#320 あたりから見ていくと読みやすいかな。
この辺りのコードでbestLocale
を取ってきて設定しているっぽい。
320 public void updateConfiguration(Configuration config, DisplayMetrics metrics, 321 CompatibilityInfo compat) { 351 // If even after the update there are no Locales set, grab the default locales. 352 LocaleList locales = mConfiguration.getLocales(); 353 if (locales.isEmpty()) { 354 locales = LocaleList.getDefault(); 355 mConfiguration.setLocales(locales); 356 } 357 358 if ((configChanges & ActivityInfo.CONFIG_LOCALE) != 0) { 359 if (locales.size() > 1) { 360 // The LocaleList has changed. We must query the AssetManager's available 361 // Locales and figure out the best matching Locale in the new LocaleList. 362 String[] availableLocales = mAssets.getNonSystemLocales(); 363 if (LocaleList.isPseudoLocalesOnly(availableLocales)) { 364 // No app defined locales, so grab the system locales. 365 availableLocales = mAssets.getLocales(); 366 if (LocaleList.isPseudoLocalesOnly(availableLocales)) { 367 availableLocales = null; 368 } 369 } 370 371 if (availableLocales != null) { 372 final Locale bestLocale = locales.getFirstMatchWithEnglishSupported( 373 availableLocales); 374 if (bestLocale != null && bestLocale != locales.get(0)) { 375 mConfiguration.setLocales(new LocaleList(bestLocale, locales)); 376 } 377 } 378 } 379 }
では「何がbestなのか」を評価するためのロジックはどうなっているかというと、LocaleList.java#302 あたり。
302 @IntRange(from=0, to=1) 303 private static int matchScore(Locale supported, Locale desired) { 304 if (supported.equals(desired)) { 305 return 1; // return early so we don't do unnecessary computation 306 } 307 if (!supported.getLanguage().equals(desired.getLanguage())) { 308 return 0; 309 } 310 if (isPseudoLocale(supported) || isPseudoLocale(desired)) { 311 // The locales are not the same, but the languages are the same, and one of the locales 312 // is a pseudo-locale. So this is not a match. 313 return 0; 314 } 315 final String supportedScr = getLikelyScript(supported); 316 if (supportedScr.isEmpty()) { 317 // If we can't guess a script, we don't know enough about the locales' language to find 318 // if the locales match. So we fall back to old behavior of matching, which considered 319 // locales with different regions different. 320 final String supportedRegion = supported.getCountry(); 321 return (supportedRegion.isEmpty() || 322 supportedRegion.equals(desired.getCountry())) 323 ? 1 : 0; 324 } 325 final String desiredScr = getLikelyScript(desired); 326 // There is no match if the two locales use different scripts. This will most imporantly 327 // take care of traditional vs simplified Chinese. 328 return supportedScr.equals(desiredScr) ? 1 : 0; 329 }
まず完全にLocaleが一致するか見ている。ここは一致しないはずなので次へ。
次にLonguageが一致しないか見ている。ここは一致するので次へ。
次にPseudo Localeか見ている。ここは当てはまらないので次へ。
最後ににscriptが一致するかを見ている。ISO15924で繁体字は Hant
と定められていて、これがいわゆる中国語の簡体字Hans
とは別扱いだった。
わざわざコメントでも書いてあった。
There is no match if the two locales use different scripts. This will most imporantly take care of traditional vs simplified Chinese.
まとめ
というわけで、values-zh
は簡体字扱いなので、端末の設定を繁體中文(繁体字)にしても文字が違うということで選択されなかったという結論になった。スッキリ!
LocaleといえばLanguageとCountry (region) くらいしか気にしていなかったけれど、script(文字) も気にしないといけなかった。 Referenceにも当然だけどscriptのことが書いてあった。ただ、これだけだとscriptがmatchingのロジックにどう関わるかわからないんじゃないかなぁ… というわけでたまにはFrameworkのコード読むの大事!٩( ‘ω’ )و
Kotlin初心者から抜け出したい?それなら、
Land of Lispを読んだらどうだろう。 (すみません。9割ネタです。)
- 作者: M.D. ConradBarski,Conrad Barski,川合史朗
- 出版社/メーカー: オライリージャパン
- 発売日: 2013/02/23
- メディア: 大型本
- 購入: 1人 クリック: 18回
- この商品を含むブログ (19件) を見る
Kotlinを初めて数ヶ月だったあなたへ
今年のGoogle I/OでKotlinがAndroidで正式サポートされるとアナウンスされた頃からでしょうか、Kotlin初心者向けの勉強会が一気に増え、実際に自分の周りもAndroidエンジニアを中心にKotlinを触る人が増えてきました。
最初はみなさんこちらの赤べこ本から始める人が多いかと思います。
Kotlinスタートブック -新しいAndroidプログラミング
- 作者: 長澤太郎
- 出版社/メーカー: リックテレコム
- 発売日: 2016/07/13
- メディア: 単行本(ソフトカバー)
- この商品を含むブログ (1件) を見る
(英語が得意な人であればKotlin in Actionもいいですね)
- 作者: Dmitry Jemerov,Svetlana Isakova
- 出版社/メーカー: Manning Pubns Co
- 発売日: 2017/02/19
- メディア: ペーパーバック
- この商品を含むブログを見る
赤ベコ本、初心者でもとっつきやすくて本当に素敵な入門書です。ただ、入門書も読み終わって初心者からそろそろもう一歩進みたいそこのあなた。
Java(特にAndroid Java1 )をずっと続けてきてからKotlinに移行した人、そんな人におすすめなのが Land of Lisp!
なぜLand of Lispがおすすめなのか
一体どこからLispが出てきたのか?この本は半分Lispについての本ですが、 半分は関数型プログラミングについての入門書です。
Kotlinは関数型プログラミングのパラダイムを取り入れています。
例えば、高階関数。公式ドキュメントを読む時も高階関数(Higher-Order Functions)とかさらっと出てきます。それが一体なんなのか、なんのためにあるのかちゃんと理解していますか?
例えば、遅延評価。Java 8が使えないAndroidでは初めましての人も多いかと思います。Kotlinではvalで再代入不可な変数を宣言することができますが、これをpropertyで持つとなると当然初期化をする必要があります。クラスのインスタンス化時点では取れない情報を使って初期化したい時、遅延評価が本当に強力な機能となります。
Androidの例で恐縮ですが、Activityのpropertyとしてintentのextraを持ちたい場合、下記のようなコードはかけません。なぜならクラスのインスタンス化時点でthis.intent
は正しいintentが入っていないからです。
class HogeActivity : AppCompatActivity() { private val fuga = this.intent.getStringExtra(FUGA_KEY) // 動かないよ! }
そこでvalで宣言するのを諦めてvarにして書けないことはないのですが、イマイチなコードです。
class HogeActivity : AppCompatActivity() { private var fuga = "" // 動くけどイマイチだよ! override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) this.fuga = this.intent.getStringExtra(FUGA_KEY) } }
そこで、下記のように遅延初期化すれば解決します。このコードの場合、初めてfuga
にアクセスしようとした時にfuga
が初期化されます。なので、例えばonCreate()
でfuga
にアクセスするとこの時にはthis.intent
に正しい情報が入っているのでfuga
には想定通りの値で初期化されます。これでfuga
をvalで宣言することができます。
class HogeActivity : AppCompatActivity() { private val fuga by lazy { this.intent.getStringExtra(FUGA_KEY) // 動くよ! } }
おまけ
そもそもなんでLand of Lispなんか読み始めたかというと昨年の誕生日に同僚氏が自宅に送りつけてきたからです。(当然wishlistには入れていません。)今まで5人くらいに送りつけて実際に読み始めたのは私が初めてだそうです😇
読了して思ったのは「この本やっぱり頭おかしいな」でした。
最後に。この本を読んで副作用満載のコードをみると発狂しそうになるかもしれませんが責任取れませんので悪しからず。
Dexopenerを使ってKotlinで書かれたfinalなclassをmockする話
[2017.7.27 追記あり]
4ヶ月以上前の話の続きです。下書きにずっと眠っていたのですが、諸事情により公開が遅くなってしまった…
こちらの記事でAndroidでKotlinのmockつらい〜〜〜 😇😇😇😇って話をしたら、
id:tmurakami さんがコメントでDexOpenerを教えてくださったので試してみました。 (id:tmurakamiさんがDexOpenerの作者さんです。)
使い方
上記RepositoryのREADMEにある通り、testコードのcompile時に対象libraryを指定して、 testInstrumentationRunner
を指定するだけです。
dependencies { androidTestCompile 'com.github.tmurakami:dexopener:0.9.1' }
android { defaultConfig { testInstrumentationRunner "com.github.tmurakami.dexopener.DexOpenerAndroidJUnitRunner" }
これだけでfinal classをmockできるようになります!楽!!
中で何が起こっているのか
せっかくなのでlibraryの中身を読んでみました。Open source最高!!
こちらのlibraryは同じ作者さんの classinjector
というlibraryに依存を持っているのでコードを読むときは手元にcloneして読むことをおすすめします。
読み間違いなどあったらすみません!ご指摘いただけると嬉しいです!
ざっくり概要
[2017.7.27 追記]
最新version( 0.10.3
)で方針が変わってかなり早くなりました!圧倒的…!
ただし、minSdkVersionが16にあがっていましたのでご注意を。
version | time |
---|---|
0.9.1 | Total time: 3 mins 37.83 secs |
0.10.3 | Total time: 9.162 secs |
dexファイルを取って来て、下記のクラス・メソッドのアクセス修飾子からfinalをマスクしてbit反転していました。
- class
- inner class
- method
というわけでクラスやメソッドが増えれば増えるほど処理に時間がかかります。
コード詳細
まずはTest Runner ( DexOpenerAndroidJUnitRunner.java
) のnewApplicationで DexOpener#install()
をコール。
このメソッドの中で ApplicationInfo
を持ったbuilderを生成し、 DexOpenerImpl
に処理を移譲します。
この時、Builderに BuiltinClassNameFilter
を設定して、buildinのclassはmockできないようにしています。
BuiltinClassNameFilter.java
private final String[] disallowedPackages = { "android.", "com.android.", "com.github.tmurakami.classinjector.", "com.github.tmurakami.dexmockito.", "com.github.tmurakami.dexopener.", "com.github.tmurakami.mockito4k.", "java.", "javax.", "junit.", "kotlin.", "kotlinx.", "net.bytebuddy.", "org.hamcrest.", "org.jacoco.", "org.junit.", "org.mockito.", "org.objenesis.", };
ApplicationInfo.dataDir
に code_cache/dexopener
でcacheを生成した後、
ClassInjector
を使ってApplicationInfo
からclasses*.dexファイルを取って来て、
AndroidClassSource.java
@SuppressWarnings("TryFinallyCanBeTryWithResources") private ClassSource newDelegate() throws IOException { List<ClassSource> sources = new ArrayList<>(); ClassNameReader r = new ClassNameReader(classNameFilter); ZipInputStream in = new ZipInputStream(new FileInputStream(sourceDir)); // sourceDirはApplicationInfoから取得したやつ try { for (ZipEntry e; (e = in.getNextEntry()) != null; ) { String name = e.getName(); if (name.startsWith("classes") && name.endsWith(".dex")) { ByteArrayOutputStream out = new ByteArrayOutputStream(); byte[] buffer = new byte[16384]; for (int l; (l = in.read(buffer)) != -1; ) { out.write(buffer, 0, l); } ApplicationReader ar = new ApplicationReader(ASM4, out.toByteArray()); sources.add(dexClassSourceFactory.newClassSource(ar, r.readClassNames(ar))); } } } finally { in.close(); } return new ClassSources(sources); }
[2017.7.27 追記]
コメントいただきました通り、最新版ではASMDEXではなくdexlib2を使う方針に変わっていました。失礼しました。
下記のfinalをbit反転しています。
- class
- inner class
- method
visitorパターンで実装されていました。ASMDEXのClassVisitor を使っているためかな?
ApplicationOpener
@Override public ClassVisitor visitClass(int access, String name, String[] signature, String superName, String[] interfaces) { int acc = access & ~ACC_FINAL; return new ClassOpener(api, super.visitClass(acc, name, signature, superName, interfaces)); }
感想
本体側をKotlinで書かれているアプリのinstrumented auto testしたい〜〜って時に選択肢の一つとして良さそうです🎉🎉
[2017.7.27 追記]
パフォーマンスもかなり良いので、minSdkVersionが16以上という方はぜひ使ってみたらいいと思います!🎉🎉
Kotlin 1.1.2-5くらいからSAM変換がちょっと変わってcrashが起きた件
Kotlinのバージョンあげたら既存コードがcrashしたのでちょっと調べてみたメモ。
何が起こったのか
kotlin のversionを1.0.5-3から1.1.2-5にあげたら既存コードがcrashするようになった。具体的には、こんな感じのIllegalStateExceptionが吐かれる。
java.lang.IllegalStateException: invoke(...) must not be null
何が悪かったか
Kotlin側に渡すJavaで定義したFunction0
のオブジェクトでinvoke()
をoverrideした際にreturn nullしていたのが悪かった。Javaでいうvoidに当たるUnitのインスタンスをreturnすることで落ちなくなる。
new SamConversionExperimentalKt().invokeFunctionFromKotlin(new Function0<Unit>() { @Override public Unit invoke() { doSomething(); return Unit.INSTANCE; // return nullするとcrashになるよ } });
Android Studio(v2.3)だとinvoke()メソッドを補完するときにreturn nullで補完してくれるからいちいち手で書き換えないといけない_:(´ཀ`」 ∠):
Javaでいうvoidはkotlinのnull許容・非許容と離れた概念だからどっち返しても大丈夫でしょ、とか思ってたら思わぬところで痛い目をみる。
で、問題はなんだったの?
実はこのcrashが発生するのにいくつか条件があって、
- JavaでFunctionN系のinvoke()メソッドをreturn nullしてoverrideする
- 1で定義したインスタンスをkotlinのコードに渡す
- 2のkotlinのコードからさらにjavaのコードに渡して、invoke()ではなくSAM変換後のメソッドとして実行する
3がちょっとなんて言ったらわからないのでコードを交えて説明。
適当なAndroidのコードで試したものです。まずはFunction0
public class SamConversionExperimental { public void invokeFunction() { new SamConversionExperimentalKt().invokeFunctionFromKotlin(new Function0<Unit>() { @Override public Unit invoke() { doSomething(); return Unit.INSTANCE; // return nullするとcrashになるよ } }); } private void doSomething() { } }
その後、受け取ったkotlinコード側でSAM変換がかかるような感じで実行してやればcrash. 手元だとRunnable#run() が走るようにしてcrashさせた。
(Runnableは単一メソッドrun()だけを持つインターフェイス)
fun invokeFunctionFromKotlin(function: () -> Unit) = Handler(Looper.getMainLooper()).post(function)
SAM変換変わったね?
エラー文から察するに、この辺りのチェックコードがversion上がって入ってきたっぽい。 github.com
public static void checkExpressionValueIsNotNull(Object value, String expression) { if (value == null) { throw sanitizeStackTrace(new IllegalStateException(expression + " must not be null")); } }
で、このメソッドがどこから呼ばれているかというと、この辺り。
private fun analyzeTypesAndRemoveDeadCode(): Map<AbstractInsnNode, Type> { val insns = methodNode.instructions.toArray() val frames = analyze(internalClassName, methodNode, OptimizationBasicInterpreter()) val checkedReferenceTypes = HashMap<AbstractInsnNode, Type>() for (i in insns.indices) { val insn = insns[i] val frame = frames[i] if (insn.isInstanceOfOrNullCheck()) { checkedReferenceTypes[insn] = frame?.top()?.type ?: continue } else if (insn.isCheckParameterIsNotNull() || insn.isCheckExpressionValueIsNotNull()) { // ここにチェックが入っている checkedReferenceTypes[insn] = frame?.peek(1)?.type ?: continue } } val dceResult = DeadCodeEliminationMethodTransformer().removeDeadCodeByFrames(methodNode, frames) if (dceResult.hasRemovedAnything()) { changes = true } return checkedReferenceTypes }
なんかこの辺りのcommitでcheck増えてそうな気配を感じるのでこれかなぁ
Intentを使って複数枚画像を取得するときのメモ
Intentを使ってPhotoとかいい感じの(=ユーザが選択した)アプリから画像を選択したいときに、1枚なのか複数枚なのかで色々違うのでメモ。
複数枚画像を選択する場合
選択させるアプリを起動するとき
ポイントは Intent.EXTRA_ALLOW_MULTIPLE
のextraをtrueに設定すること。
色々ググったときにactionが Intent.ACTION_GET_CONTENT
でいけると書いていたけど、これだと複数画像選択できず一枚選択した時点で元のアプリに戻ってしまう挙動になった。(OS 7.1.2/Nexus 6P実機環境)
下記コードのように Intent.ACTION_PICK
だと動く。
val intent = Intent() intent.type = "image/*" intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) intent.action = Intent.ACTION_PICK this.startActivityForResult(Intent.createChooser(intent, "Choose Photo"), CHOOSE_PHOTO_REQUEST_CODE)
選択した画像の情報を取り出すとき
Activity#onActivityResult()
で受け取るintentの clipData
にuriの情報が入っているのでこれを使う。
下記のサンプルコードは選択した画像のuriの情報を取り出してlistViewに表示するコードの一部。 getItemAt()
で各要素にアクセスできる。
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == CHOOSE_PHOTO_REQUEST_CODE && resultCode == Activity.RESULT_OK) { val itemCount = data?.clipData?.itemCount ?: 0 val uriList = mutableListOf<Uri>() for (i in 0..itemCount - 1) { val uri = data?.clipData?.getItemAt(i)?.uri uri?.let { uriList.add(it) } } this.adapter.data = uriList this.adapter.notifyDataSetChanged() } }
余談だけど、 clipData
が保持しているuriの情報を持っているArrayListがpublicではない、かつ直接アクセスするためのAPIが公開されていないのでこんな汚い感じでmutableListだったりfor文だったりを使わないといけない感じになった(´;ω;`)
↓ClipDataのコード
Cross Reference: /frameworks/base/core/java/android/content/ClipData.java
153 public class ClipData implements Parcelable { 167 final ArrayList<Item> mItems; // publicじゃない 819 /** 820 * Return a single item inside of the clip data. The index can range 821 * from 0 to {@link #getItemCount()}-1. 822 */ 823 public Item getItemAt(int index) { 824 return mItems.get(index); // 指定したindexの要素しか取れない 825 }
もしimmutableなmItemsがgetできたり別のiterableな何かが取得できるAPIが生えてたら、こんな感じでもっと綺麗にかけるのに…
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == CHOOSE_PHOTO_REQUEST_CODE && resultCode == Activity.RESULT_OK) { // もしmItemsに外部からアクセスできたらこんな感じで1行で済ませられそう this.adapter.data = data?.clipData?.items.map { it.uri } ?: emptyList() this.adapter.notifyDataSetChanged() } }
拡張関数とか自分で書いてもいいけど、そんなに利用頻度高くなさそう、ということで諦めてしまいそう。
1枚だけ画像を選択する場合
選択させるアプリを起動するとき
この場合のactionは Intent.ACTION_GET_CONTENT
でいけた。
val intent = Intent() intent.type = "image/*" intent.action = Intent.ACTION_GET_CONTENT this.startActivityForResult(Intent.createChooser(intent, "Choose Photo"), CHOOSE_PHOTO_REQUEST_CODE)
選択した画像の情報を取り出すとき
取り出すときは複数枚の場合と同じく、Activity#onActivityResult()
で受け取るintentの clipData
にuriの情報が入っているので同様に扱うことができる。
MediaStoreのThumbnailsの罠
先日参加したCA.apk #2 で前川さんが発表されていた「やさしい画像ギャラリー改善tips」がいい感じだったので、試してみた結果と気になった部分のメモ書きです。
発表の概要
画像ギャラリーを作る時に、MediaStore.Images.Media.EXTERNAL_CONTENT_URI
だと画像の元サイズで読み込んでメモリが逼迫されるので、MediaStore.Images.Thumbnails.EXTERNAL_CONTENT_URI
を使って小さいサイズで読み込むと動作がサクサクになるよ!という話だと理解しました。
気になったこと
基本的にいい感じなのですが、thumbnailのテーブルだとコンテンツが登録されない契機があるように見えます。具体的にいうと、ギャラリーを表示した直後にカメラアプリを起動し、ギャラリーに戻って来た際にthumbnailのテーブルには先ほど撮った写真はまだ登録されていません。(MediaStore.Images.Media.EXTERNAL_CONTENT_URI
には登録されていました。)手元のNexus 6Pでしか試せていないのですが、/storage/emulated/0/DCIM/.thumbnails/
配下のキャッシュされたファイルしか探しに行けてないような?thunbmail生成のタイミングに気をつけたほうがよさそうです。
で、どうする?
確実な方法としては、MediaStore.Images.Media.EXTERNAL_CONTENT_URI
でちゃんと最新のデータが登録されているので、このテーブルからID一覧を取得するのが楽そうです。こっちのテーブルにはthumbnailのテーブルのようにKIND列が用意されていないので、サイズを指定してデータを参照することはできません。というわけで、Thumbnails.getThumbnail()
を使って元データのIDからthumbnail画像のbitmapを取得するのが正攻法なのかなぁという気がしています。パフォーマンスはちょっと下がる気がしているので、他にいい方法があれば教えてください。
おまけ:thumbnail テーブルが更新されるタイミングを知りたくてframeworkを読んで見た
MediaProvider
でupdate()
が呼ばれたタイミングでthumbnailが生成されているけど、insert()
のタイミングではthumbnailは生成されていないような気がする…1
ということはMediaScannerでscanすればいいのでは?と思ってMediaScanner#scanDirectories()
とか使いたかったけどそもそもクラスが @hide
だし、publicなMediaScannerConnection#scanFile()
はディレクト単位でscanできるのかわからないので諦めました :p
BottomNavigationViewのコード読んでみた
このエントリは先日参加した「まったりAndroid Framework Code Reading #5」の成果です٩( ‘ω’ )و
Support LibraryのBottomNavigationViewのコード読んできたのでまとめるよ!
BottomNavigationViewとは
Support Library 25.0.0から追加された画面下部に配置するviewです。
API Referenceはこちら
なぜBottomNavigationVIewを読もうと思ったか?
iOSではおなじみの下タブデザインをAndroidでも取り入れたいという話はよく出てくるかと思いますが、このBottomNavigationViewはボタンの数が多くなってくるほどめちゃめちゃアニメーションが入ってきます。
アニメーションいらないし、inactiveなボタンだとtext表示されないしで、割とプロダクトに取り入れるのはハードル高かったりします。 そういった場合、自分でcustom viewを作ることになると思うのですが、次に問題になるのはmenuをxmlで設定できるようにするか?という部分になります。 menuで設定できた方が綺麗だけど、そこまで汎用性高める必要もないしもはやコード量によるよなぁと思って、では本家ではどれくらいのコード量なのか?を知るために読んでみました。
で、どうだった?
アニメーションを含めるとBottomNavigationViewを構成する関連クラスは7つほどでした。というわけでmenuで設定できる必要はないかなーという結論。Library作っていろんなプロダクトで使い回すなら別ですけどね。
わかったこと
関連コードとそれぞれ読んだ時のメモを残しておきます。
BottomNavigationView
- コードはここ
- FrameLayoutをextendsしたクラス
- prensenter, menu, menuViewをそれぞれお互いにバインド
- custom attributeを取得してmenuViewにセット
- custom attributeからmenuを取ってきてinflateするメソッドがpublicなので、コードから任意のタイミングでinflateできるっぽい
181 /** 182 * Inflate a menu resource into this navigation view. 183 * 184 * <p>Existing items in the menu will not be modified or removed.</p> 185 * 186 * @param resId ID of a menu resource to inflate 187 */ 188 public void inflateMenu(int resId) { 189 mPresenter.setUpdateSuspended(true); 190 getMenuInflater().inflate(resId, mMenu); 191 mPresenter.initForMenu(getContext(), mMenu); 192 mPresenter.setUpdateSuspended(false); 193 mPresenter.updateMenuView(true); 194 }
- このView自体はただのFrameLayoutでコンテナ状態
- menu viewをaddView()することによりViewの描画を行う
- menu viewがさらにitem viewを再帰的にaddViewしている
- menu viewをaddView()することによりViewの描画を行う
- itemIconTintがattributeで指定されていなかったら、disable/ emptyには
android.R.attr.textColorSecondary
がが設定される。checkedはprimary - BottomNavigationMenuにcall backを設定
- 提供するinterfaceはonMenuItemSelected()とonMenuModeChange()
BottomNavigationMenu
- コードはここ
- 50行程度の小さなクラス
- MenuBuilderクラスの拡張
- @hideかつpublic
- このクラスのMAX_ITEM_COUNTは5
- sub menuは非サポート
BottomNavigationMenuView
- コードはここ
- 300行程度のクラス
- ViewGroupの拡張、MenuViewをimplements
- @hideかつpublic
- BottomNavigationViewから強制的にGravity.centerをセットされる
111 mMenuView = new BottomNavigationMenuView(context); 112 FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( 113 ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); 114 params.gravity = Gravity.CENTER; 115 mMenuView.setLayoutParams(params);
- initialize()でメンバで持ってるMenuが更新されてもcheckedの位置はリセットされず引き継ぐ
97 @Override 98 public void initialize(MenuBuilder menu) { 99 mMenu = menu; 100 if (mMenu == null) return; 101 if (mMenu.size() > mActiveButton) { 102 mMenu.getItem(mActiveButton).setChecked(true); 103 } 104 }
- BottomNavigationItemViewの配列を持っている
- Pools.SynchronizedPoolというhideなクラスを使ってButtonのオンジェクトをプールしている
- Buttonが3つ以上ある場合はアニメーション(shifting mode)
- アニメーション機能ははBottomNavigationAnimationHelper* クラスに移譲
- ここでいうアニメーションとは、ボタン自体の移動をさす
- ボタンのコンテンツのサイズが変わるtransitionはBottomNavigationItemViewの責務
BottomNavigationItemView
- コードはここ
- 実際のボタンを担当するクラス
- FrameLayoutをextends, MenuView.ItemViewをimplements
- active/inactiveが切り替わったタイミングでボタン内のコンテンツ(lable, icon)の拡大・縮小、表示・非表示の制御を行う
BottomNavigationPresenter
- コードはここ
- 100行程度の小さなクラス
- MenuPresenterをimplements
- MenuPresenterはhideなinterface
- @hideかつpublic
- updateViewのロックは意外とフラグ制御
BottomNavigationAnimationHelperIcs
- コードはここ
- ボタンが3つ以上だった時のanimationのhelper class
- AutoTransitionとTransitionManagerを使ってanimation
- material motionの土台となってるのはこのクラスっぽい
- 意外とめっちゃシンプルなコードでかけるっぽい
30 BottomNavigationAnimationHelperIcs() { 31 mSet = new AutoTransition(); 32 mSet.setOrdering(TransitionSet.ORDERING_TOGETHER); 33 mSet.setDuration(ACTIVE_ANIMATION_DURATION_MS); 34 mSet.setInterpolator(new FastOutSlowInInterpolator()); 35 TextScale textScale = new TextScale(); 36 mSet.addTransition(textScale); 37 } 38 39 void beginDelayedTransition(ViewGroup view) { 40 TransitionManager.beginDelayedTransition(view, mSet); 41 }
BottomNavigationAnimationHelperBase
- コードはここ
- ICSより前のOSだとこっちが使われる
- 中身はこれだけ(つまりanimationしない)
21 class BottomNavigationAnimationHelperBase { 22 void beginDelayedTransition(ViewGroup view) { 23 // Do nothing. 24 } 25 }