TouchDelegateを使ってタッチ可能領域を拡張する

Buttonのクリックに反応する範囲を拡張したい時がある。 今回は下図のようなカスタムViewを作りたかった。 ImageButtonとTextViewを内包したViewである。 親レイアウトのViewGroupをタッチする=ImageButtonをタッチするという扱いにしたかった。 ImageButtonにしているのは、他のViewとの兼ね合いである。 他のViewはImageButtonにstyle="@style/Base.Widget.AppCompat.ActionButton"を適用して、アイコン画像の周りにだけリップルエフェクトがかかるようになっていて、このカスタムViewもそれに合わせたかったのである。 これはTouchDelegateを使うと実現できる。 TouchDelegate – Android Developers 使い方 TouchDelegateは親のViewGroupに設定する。 TouchDelegateのインスタンスを作成するには、タッチエリアを表すRectと委譲先のView(今回でいうとImageButton)への参照が必要。 Rectはローカル座標系(ディスプレイ上での絶対座標ではなく、ViewGroupの左上を0とした相対座標)を使う。 今回はViewGroupのgetDrawingRect()を使って取得したRectを使った。 ぐぐるとgetHitRect()を使った例がみつかったが、これだとうまく動かなかった。絶対座標になっているからだと思われる。 コード例 class CommentStatusView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ConstraintLayout(context, attrs, defStyleAttr) { private lateinit var binding: ViewCommentStatusBinding init { binding = ViewCommentStatusBinding.inflate(LayoutInflater.from(context), this, true) doOnPreDraw { val rect = Rect() binding.viewGroup.getDrawingRect(rect) binding.viewGroup.touchDelegate = TouchDelegate(rect, binding.imageButton) } } override fun setOnClickListener(l: OnClickListener?) { binding.imageButton.setOnClickListener(l) } } Kotlinで、Android-ktxとDataBindingを使っている。 doOnPreDrawがAndroid-ktxを使っている部分で、ViewTreeObserverを利用してViewの大きさが決まった後で中のブロックの処理(TouchDelegateの設定)を行っているだけである。 後はこのカスタムViewに対するsetOnClicklistenerをImageButtonに対して設定するようにしている。 ポイントは 親のViewGroupに対してTouchDelegateを設定する rectはTouchDelegateを指定するViewGroupからみて、どの位置のタッチイベントを委譲先に渡すのかをローカル座標系で指定する ドキュメントに書いてあるとおりなのだが、英語力がなくていまいちわからず、ググったコードを参考にしながらやってもうまくいかずでちょっとハマった。 実際の動きはこんな感じになった。 リップルエフェクトもクリックリスナもViewGroupに対するものがImageButtonを押している扱いになっている。

TextViewに設定したテキスト内のURLに遷移する

TextViewに設定したテキスト内にURLがあった場合に、そのリンクをクリックできるようにしたい。 クリックしたらブラウザが開いて該当ページに移動できるようにしたい。 手っ取り早くこの要望を満たそうと思ったら、TextViewには便利な機能が用意されている。 TextViewにandroid:autoLink="web"を追加すればよいだけである。 これでテキスト内のURLをクリックしたらブラウザが開いてくれる。 めでたしめでたし。 といくなら楽でよかったのだが、この機能によるURLの処理はあまり正確ではない。 autoLinkの問題点 URLが半角スペースで区切られていたり、2バイト文字以外で区切られていたりしたら正しくリンクとして拾ってもらえる。 例えばあいうえお https://android.gcreate.jp/ かきくけこというテキストであればURLの部分のみがURLとして識別される。 しかしあいうえおhttps://android.gcreate.jp/だとリンクを拾ってくれない。 またあいうえお https://android.gcreate.jp/がリンクになってほしいとした場合、「がリンクになってほしい」という部分までURLとして拾われてしまう。 2バイト文字でない半角カッコで囲ってかっこで(https://android.gcreate.jp/)とした場合、閉じカッコもURLに含まれてしまう。 URLの抽出がうまくいかない場合があるのが最大の問題であるが、URLのハンドリングをカスタマイズできないのもちょっと不便である。 例えばChromeカスタムタブでリンクを開きたい場合に、autoLinkでは対応できない。 autoLinkの場合、リンクをクリックするとACTION_VIEWの暗黙的インテントが発行される。 自前で処理する 以上の問題点を回避するには、自分でテキストにClickableSpanを設定してやると良い。 テキストをSpannableStringに変換する テキストから正規表現を利用してURLを抽出する 抽出したURLを用いてSpannableStringにClickableSpanを設定する TextViewにSpannableStringをsetTextで設定する TextViewにsetMovementMethodを設定する 以上の手順で独自のClickableSpanを設定することができる。 コード的にはこんな感じ(Kotlinとandroid-ktxを利用している)。 val text = "あいうえおhttps://android.gcreate.jp/かきくけこ" val spannable = text.toSpannable() val matcher = Pattern.compile("(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]").matcher(text) while (matcher.find()) { val url = matcher.group() val start = matcher.start() val end = matcher.end() spannable.setSpan(MyUrlSpan(url), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } textView.text = spannable textView.movementMethod = LinkMovementMethod.getInstance() MyUrlSpanは自分で定義する。 といっても大したことはやっていない。 class MyUrlSpan(val url: String) : ClickableSpan() { override fun onClick(view: View) { Snackbar.make(view, "$url clicked", Snackbar.LENGTH_SHORT).show() } } SnackbarでURLを表示しているだけ。 リンクをクリックした際の挙動はこのonClickでカスタマイズできる。 もう少し詳しく このあたりの挙動が知りたければ、TextViewのソースコードを確認するのが手っ取り早い。 mAutoLinkMaskをキーに見ていくとだいたい分かると思う。 ClickableSpanを設定する際に必要なのは、文字列中のどこからどこまでにClickableSpanを適用するかが必要になる。 これは正規表現を使ってマッチさせれば該当する文字列、その文字列の開始位置・終了位置が分かるのでそれを使えば良い。 正規表現はこちらの記事を参考にさせていただいた。 AndroidのTextViewのautolink=webが冗長になる setSpan()する際の第4引数はflagsである。 https://developer.android.com/reference/android/text/Spanned これはEditTextにSpanを設定しないのであれば、おそらく何を設定しても影響はないと思う。 (Spanを設定したテキストの内容が動的に変化する場合に、変更前に設定したClickableSpanの開始位置・終了位置がどう変動するかを指定するフラグだと思うので) TextViewのTextをSpannableにして、ClickableSpanを設定しただけではリンクをクリックすることはできない。 ClickableSpanのonClickが呼び出されるためには、setMovementMethodで何らかのMovementMethodがTextViewに設定されていなければならない。 autoLinkを使った場合のLinkをタップしたときの動きはLinkMovementMethodが使われているのでここはそのまま流用する。 ClickableSpanのonClickを呼び出すかどうかは、TextViewのonTouchメソッド内の処理を確認すれば分かるが、mMovementがnullではないことが条件になっている。 だからTextView.
Read full post gblog_arrow_right

Adaptive Icon

Adaptive IconをFigmaを使って作ってみた。 Adaptive Iconは、開発者側はアプリのアイコンとなる前景画像と背景画像の2種類だけを用意して、後のアイコンの形はOS(デバイス側)にまかせてしまうという仕組み(という理解を私はしている)。 今まではランチャーアイコンとして四角いやつと丸いやつの2種類を別途用意していたけども、これからは2種類の画像(前景と背景)さえ用意しとけば後はOS側(正確にはホームアプリ?)がアイコンの形をよしなに表示してくれる。 といってもAPI26からの機能なので、現実的には依然として普通のランチャーアイコンは用意しなければならない。 とはいえ今回Figmaを使ってアイコンを作ってみたが、25以下のための画像データを用意するのはそこまで手間だとは感じなかった。108dpでアイコンデータを作っているので、その画像をAndroid Asset Studioに持っていってランチャーアイコンを生成するだけだったので。 Figmaを使ってアイコンデータ作成 今回はFigmaを使ってアイコンデータを作成した。ちなみにベクターデータである。 作成したforeground画像とbackground画像を、Adaptive Iconとして表示した場合にどうなるかは、このツールを使ってシュミレーションしながら確認した。 Adaptive Icon非対応の端末用の既存のランチャーアイコンは、Figmaから書き出した画像データをAndroid Asset Studioを使って生成した。 こんな感じで作成。 これは重ね合わせた状態だが、前景と背景別々に作ってある。 どちらの画像も108dp四方になるように、108dpの矩形を別途用意した上で、その上にアイコン要素を描画するようにしている。そうしないとエクスポートした際に108dpの画像として出力できないからである。 作成した画像はforegroundとbackgroundそれぞれをSVGでエクスポートして、そのSVGファイルをVector Drawableに変換してAndroid Studioへ持っていった。 Vector Drawableへの変換はこのツールを利用した。 複雑な形状だとうまく変換できないこともあるので、適宜調整が必要だろう。Vector Drawableとしてうまく変換できたとしても、パスデータが複雑すぎるという警告が出ることもある。あまり複雑すぎるのも考えものである。 ちなみにFigma用のAdaptive Iconのテンプレートがあったので、こちら から利用させていただいた。 テンプレートのサイズはFigma上では432になっている。Adaptive Iconは108dpで作成するものだ。FigmaとかSVGの仕様とかに疎いのでよくわからないのだが、432のサイズでアイコンを作成してSVGでエクスポートしても問題ないのだろうか? 私はよくわからなかったので、アイコンサイズを108の大きさで作成した。 ちなみに432とか単位をつけずに書いているのは、Figma上での単位が分からなかったからである。432のままSVGエクスポートしてVector Drawableに変換すると432dpになってしまい、そこからXMLの数値を108dpに書き換えるだけで問題ないのかよくわからなかったので、最初からFigmaの段階で108のサイズで作成したというわけ。 ちなみにテンプレートは一旦フラット化してベクター情報に変換し、サイズを108に縮小してアイコン画像の位置調整のガイドとして利用させてもらった。 Adaptive Iconの対応状況 こうして作成したAdaptive Iconだが、これが表示できるかどうか使っているランチャー次第のようだ。 私の場合、実機Nexus6P(Android 8.1.0)に作ったAdaptive Iconのアプリをインストールしても、固定されたアイコンとしては表示されるものの、アニメーション(ぷるぷる動くような視覚効果)はしなかった。 Pixel Launcherだとぷるぷるするらしい。 Pixel2のLauncherでアイコン長押ししてふったりすると動いたりします — takahirom (@new_runnable) 2018年1月12日 私の使っているランチャーはGoogle Nowランチャーなので、Adaptive Iconにきちんと対応しているわけではないようだ。アイコンとして表示することはできるが、アイコンのシェイプを変えたり、アニメーションしたりしない。 試しに他のランチャーをインストールして試してみたところ、Adaptive Iconへの対応を謳っているランチャーであればAdaptive Iconがぷるぷるすることを確認した(私が試したのはNova Launcher)。 参考 3分で分かる?Android OのAdaptive Iconに対応しよう Adaptive Iconのシミュレーションツール(Web) Designing Adaptive Icons(各ツールにおけるテンプレートへのリンクあり) SvgToVectorDrawableConverter.Web(SVGをVector Drawableに変換するツール)

スワイプで削除できるRecyclerViewを実装するときの悩み

RecyclerViewを使うときに必ず実装するであろうRecyclerAdapter。List<Hoge>をRecyclerViewに表示するのに使う。 単にリストを表示するだけならあまり迷わないのだが、プラスアルファの処理を行う必要が出てきたときに私はよく悩む。例えば、現在進行形でもにょっているのが、リストのアイテムををスワイプしたらそのアイテムを削除したいというケース。とりあえず実装して動いてはいるのだが、削除に関するコントロール処理をどこに書くのが適切なのだろうかという疑問に対する明快な解を持ち合わせていない。 最近触っていないけど、FilteredHatebuというアプリでは削除に関する処理をPresenterに担わせた。Adapterは単にList<Hoge>とRecyclerViewの橋渡しをするだけというシンプルな作りだ。 一方で、現在作っているアプリではAdapterで削除に関する処理を行っている。この2つの違いがなぜ生まれたかというと、AdapterがList<Hoge>を持っているかどうかという問題に行き着く気がする。 RecyclerViewやListViewを使うとき、ネットで見かけるコードではAdapterにList<Hoge>を持たせるものをよく見かける。コンストラクタを使って渡すなり、セッターを使うなりして、AdapterにList<Hoge>をセットしてやる手法だ。単に表示するだけならこれで問題はないのだが、削除に関する処理を行おうとすると混乱し始める。 削除処理はList<Hoge>のアイテムを削除する処理を内包する。RecyclerViewの2番めのアイテムがスワイプされたら、List<Hoge>の2番めのHogeを削除しないといけない。ではその削除を実行するのは、AdapterなのかそれともActivityなのか、それとももっと他のもの(例えばPresenter)なのかがよくわからない。 List<Hoge>の操作が必要なのだから、List<Hoge>を管理しているものが削除すれば良い。となったときに、AdapterがList<Hoge>を持っていることが多いので、そのままAdapterに削除処理を実装することが多いのである。 削除可能なRecyclerViewの実装について、ベストプラクティスが知りたい。そして知りたいと思ったときに、ふと「そもそもAdapterにList<Hoge>を持たせるのはどうなんだろうか」と疑問に感じたのである。 私の理解では、AdapterはList<Hoge>とRecyclerViewの橋渡しをするもの、つまりHogeクラスを表示するためのViewに変換するのがその責務という認識だ。その認識からすると、AdapterにList<Hoge>をもたせて削除に関する処理が加わっている今作っているAdapterは、AdapterではなくてControllerになってる気がする。 そんなことを考えていると、そもそもRecyclerViewでアイテムをスワイプして削除させるのが間違っているのではないかという気分にもなってくる。別にAdapterにどれだけの責務をもたせるかは、開発者のさじ加減であって、個人の好きなようにしたらいいのかもしれない。 そんな堂々巡りのはて、まあ動けばいいかという結論に落ち着く。削除可能なRecyclerViewの実装、みんなはどうやっているのだろう。

コードから生成したViewにstyleを適用してもLayoutParamsについては無視される

コードから動的にViewを生成したい時がある。そしてそのとき見た目をカスタマイズしたいなんてときがある。もちろんsetBackground()とかsetPadding()を呼び出して設定することは可能であるが、どうせならXMLでやるときのようにstyleを適用したい、なんて場面があるだろう。・・・私にはあった。 さてそんなときに、どうやったらJavaのコードでnewしたTextViewにstyleを適用できるのだろうか、という話。2つ方法があって、どちらもコンストラクタでstyleを指定する。 1つはViewのコンストラクタに引数を4つとるものを使う方法。new TextView(context, null, 0, R.style.some_style);という感じでTextViewを生成する。ただし引数4つのコンストラクタはAPI21からしか存在しないので注意1。 もう1つはContextThemeWrapperを使う方法。new TextView(new ContextThemeWrapper(context, R.style.some_style));という感じで生成する。こちらも同じくJavaコードから生成したViewに、styleを適用することができる2。droidkaigiのアプリにコントリビュートしたら学べた方法3。 ただし、どっちの方法でstyleを適用しようとも、LayoutParamsに関する設定だけは無視されることに気をつけたい。私はstyleにandroid:layout_marginを指定していたのだが、Javaのコードから生成した場合、marginが無視された。 レイアウトXMLでstyleを適用した場合は、marginも含めてstyleが適用される。しかしコードからだと適用されない。これはコードからstyleを適用した場合、コンストラクタでViewを生成した時点ではViewがLayoutParamsを持たないからだと思われる。 そもそもLayoutParamsはView自身が使う情報ではなく、そのViewを配置するViewGroupが利用する情報になる。コードから生成した場合、このLayoutParamsは親レイアウトにaddView()を行った時点で設定される。もちろんsetLayoutParams()を呼び出すことで事前に設定することも可能だが、コンストラクタを呼び出しただけでは生成されないことがポイント。つまり、View自身に関わるpaddingなどの情報はstyleの適用で設定されるけれども、LayoutParamsに関する情報は設定されないということである。 コンストラクタでLayoutParamsも持たせればいいのにと思うかもしれないが(私も思ったが)、どのViewGroupに配置されるのかがわからないので、コンストラクタの時点でLayoutParamsを設定することは無駄なのだと思う。プログラマが事前にどのViewGroupに配置するか決めているのであれば、setLayoutParams()を使えということなのだろう。 そもそもstyleにLayoutParamsに関する情報をもたせることが間違いなのかもしれない。今まで特に気にせずにstyleでLayoutParamsに関する情報を持たせていたが、実は推奨されないやり方だったのだろうか。 ちなみに、今回の出来事ではじめて知ったのだが、XMLでandroid:layout_xxxとなるのがLayoutParamsらしい。どれがLayoutParamsなのかわからないじゃないかとか思ったけど、自明だった。 ちなみにViewクラスにはsetStyle()みたいなメソッドは存在しない。よく考えてみると、XMLファイルでstyleを適用する際に、なぜかstyleだけはnamespaceがつかない。基本的にViewに要素を書くときは、android:xxxだったりapp:xxxといった感じで頭に必ずnamespaceをつけるのに、直接style="xxx"と書く。つまりstyleの適用だけは特殊な扱いなのだろう。 setContentView()からだとstyleに書いたLayoutParamsが有効になるが、addView()だと無視されることも、このあたりが関係しているのかもしれない。 ちなみに引数3つのコンストラクタの第3引数はdefStyleAttrで、`R.styleable.xxx`を指定するためのものであり、styleを渡したところで適用されない。 ↩ LayoutInflaterがViewをinflateするときにこの方法でinflateしている。 ↩ https://github.com/DroidKaigi/conference-app-2017/pull/401/commits/1812e77a4e3cb598e94714cf12cd83b01d716c79#diff-13d7c85c29370a83d0d27462c1d57f2aR76 ↩

BottomNavigationViewの上にSnackbarが表示されるようにしつつFABも連動して動くようにする

BottomNavigationViewを使ってみようかなと思ったときに、ふと「Snackbarはどこに表示されるのが正しいのか」ということを疑問に思った。ガイドラインではBottomNavigationViewの上からSnackbarが現れるようにするということが書いてあった。 https://material.io/guidelines/components/bottom-navigation.html#bottom-navigation-specs Elevation的にはSnackbarがBottomNavigationViewより下にあるので、「下に配置する」というべきなんで、上から現れると表現するのも誤解がありそうな気がして気持ち悪い。 実装 挙動はわかったが、ではそれをどうやって実装すればよいのかという話になると、これがややこしい。いろいろ探し回ったが、こちらのサイトを参考にするのが良さそうな感じであった。 https://sakebook.hatenablog.com/entry/2017/02/12/032501 結論から言うとCoordinatorLayout.Behaviorを継承して、カスタムビヘイビアを使って実装するしかないようだ。今のところは。それとも、もしかしたら、私が見つけられなかっただけで、もっと簡単な方法があるのかもしれない。 FABを追加した場合はどうするのか BottomNavigationViewを配置して、さらにFABも一緒に配置したい場合はどうするのか。 つまりこういう動きをしたい、ということである。 SnackbarはBNVの上辺から現れる FABはSnackbarを避ける SnackbarはBNVの動きに合わせて動く=FABも連動して動く FABはBNVも避ける BNVはスクロールに合わせて隠れる(Appbarが隠れるのと連動する) 単純にSnackbarがBNVの上辺から出現してくれれば(SnackbarがBNVを避けてくれれば)ことは簡単なのだが、そういう設定にたどり着くことができず、最終的にcustom behaviorでゴリ押しした。 コードはGitHubにあげておいた。 どうやったか FABがBNVを避ける これは原理をいまだ理解していないのだが、BNVにapp:insetEdge="bottom"を加えることでFABがBNVを避けるようになる。 これに気づくまでが非常に長くて、ここで俺の苦労を聞いてくれと言いたいところだが割愛する。とりあえず、FABがSnackbarを避けるのはBehaviorによるものではなかったというのが今回の作業で得られたもっとも大きな収穫かもしれない。 insetEdgeの挙動に詳しい人、もしくは詳しく解説したブログ記事なんかをご存じの方は教えて欲しい。 BNVを隠す スクロールに合わせてBNVを隠す。 このあたりからこちらのサイトを参考にしだす。 https://sakebook.hatenablog.com/entry/2017/02/12/032501 私はAppbarLayoutが隠れている比率を計算して、同じ比率だけBNVを隠すという実装を行った。最初はAppbarLayoutのBehaviorを真似しようと思ったが、ややこしかったので途中で諦めた。 ちなみにAppbarLayoutを動かさないで、この仕様を取り入れたいという場合は、onNestedScrollなどを使って自分で隠すようにする必要があるだろう。 この実装にしたのはその手動計算が面倒くさかったというのもある。 やり方としては custom behaviorで`layoutDependsOn`を使いAppbarLayoutに依存するように宣言 `onDependentViewChanged`でAppbarLayoutがどれだけ隠れているかを計算する 同メソッド内でBNVの`setTranslationY`を使ってBNVを隠す やっていることはこれだけである。 SnackbarをBNVの上に表示する これが一番苦労した。参考にしたサイトでは、Snackbar表示中はBNVを動かさない、というやり方での実装だった。私の場合はSnackbar表示中だろうとBNVは動くし、それに合わせてSnackbarも動く。 custom behaviorで`oayoutDependsOn`を使いSnackbar.SnackbarLayoutに依存するよう宣言 `onDependentViewChanged`でSnackbarが出現したことをフラグで持つ Snackbar表示中は、`onNestedPreScroll`でSnackbarのpaddingを更新する `onDependentViewRemoved`でSnackbarが消えたらフラグをクリアする なぜ`onDependentViewChanged`のみでやらないのかというと、このメソッドはSnackbarがニョキッと動いている最中は呼ばれるのだが、完全に表示されてSnackbarが停止した状態では呼び出されない。そのため、Snackbarが停止している間にBNVを動かすと、その間はSnackbarが置いてけぼりになってしまうからだ。 BNVの動きに連動してSnackbarのpaddingを更新しなければならないので、こんな変な実装になってしまった。 BNVのbehaviorがSnackbarの動きを制御するという若干の気持ち悪さがあるが、他に方法を思いつかなかった。 insetEdgeをうまく使えばもっと簡単なのでは? と思っていろいろ試したのだけど、結局良くわからなかったのでこのような実装になった。 insetEdgeのinsetが何のことかよくわかっていない。似たようなやつにdodgeInsetEdgeなるものもある。dodgeInsetEdge="bottom"を設定したら、画面上部に向かってViewが飛んでいって、呪いの館を思い出した。 insetEdgeの使い方を詳しく解説しているサイトをご存知だったら教えて欲しい。 コードの全体(といっても、重要なのはcustom behaviorだけ)はGitHubにあるので参照してほしい。 ちなみにこのコードはsupport library 25.3.1で動作確認している。バージョンによって挙動が変わると思うので、注意してほしい。

setLayoutParamsには親のViewGroupに属するLayoutParamsをセットする

基本的に私はViewをJavaのコードで生成することは稀なので、適当にやっていたのだけれど。 RelativeLayout parent = new RelativeLayout(context); TextView hope = new TextView(context); parent.addView(hoge); みたいな感じでコードから生成したとき、このViewのwidth/heightにmatch_parentとかwrap_contentを設定するのに、LayoutParamsを使うことになると思う。 parent.setLayoutParams(new RelativeLayout.LayoutParams(RelativeLayout.MATCH_PARENT, RelativeLayout.WRAP_CONTENT)); hoge.setLayoutParams(new RelativeLayout.LayoutParams(RelativeLayout.MATCH_PARENT, RelativeLayout.WRAP_CONTENT)); みたいに。そこでLayoutParamsを指定するのに、なぜいちいちRelativeLayoutとかクラスを指定しているのだろうと、毎度のこと不思議に思っていた。別にViewGroupでもいいんじゃないのかとか思いながら、基本的にはAndroid Studioの補完によって出てきたものをそのまま使っていた。 とりあえずViewGroup指定しとけばオッケーだと思っていたのだが、どうもそれはオッケーではなかったらしい。たまたま今まで問題に出くわしていなかっただけだった。 親のViewGroupのLayoutParamsを使わなければならない まあ大抵の場合、ViewGroupを使っておけば問題ないのだろう。LinearLayoutとかRelativeLayoutとかFrameLayoutとかにコードから生成したViewを追加していくだろうから。しかしバージョンによってはそれではうまく動かないことがあるらしいということがわかった。 私が出くわしたのは、ListViewに表示するViewをコードから生成したときに、ViewGroup.LayoutParamsを使うとクラッシュするという症状だった。ちなみにAndroid7で確認したら問題なく動いていたので、普通にスルーしていた。4.4で確認したらクラッシュした。 ListViewもViewGroupを継承しているのだからViewGroupでも問題ない気がするのに・・・。 ちなみにこれはAbsListView.LayoutParamsを使うことで、4.4でも7でもクラッシュしなくなくなった。 この問題から学んだことは、LayoutParamsは親のViewGroupに属するLayoutParamsを使わないといけないのだということである。LayoutParamsいっぱいあって適当に選んでいたが、今後はちゃんと指針が持てたという意味で、よい経験ができた。

カスタムViewが想定通りに描画されているかテストする

カスタムViewを作って、しかもそれがCanvasを使って描画するようなものだった場合、どうやって動作確認をしていますか? 私はこれまで実機で動かして、目視で確認していました。Viewの見た目なので目視で確認するしかないんですけどね。それを手動でやっていました。 しかしつい先日、手動での確認が難しい案件に出くわしました。それは端末のセンサーの値を読み取って、その値にあわせてカスタムViewの描画が変わるようなものでした。これは手動で確認したくとも難しいです。 例えば心拍数を元に描画が変わるカスタムViewを想像してみてください。心拍数が120を超えたら特殊な表示を行う仕様だと思ってください。実機でそれを確認しようと思ったら、心拍数を上げるべく毎回運動しなきゃいけない、なんてことになるわけです。 そういったViewの描画、見た目の確認がしたい。こういうの、みんなどうやってテストしているのだろう。それが今回の出発点です。 サンプルプロジェクトをGitHubに置いてみたので良かったら見てみてください。というよりコードの解説はこの記事では一切ありませんので、GitHubでみてください。 やり方書かないのもあれなので、追記しました。 サンプルについて TextViewの周りを線でデコレーションするカスタムViewがテスト対象です。どこを描画するかを指定してinvalidate()すると、TextViewの周りに線が描画されます。onDrawメソッドをオーバーライドして、Canvasを使って線を描いています。 今回はこの描画がちゃんとできるかを確認する、というそんなテストです。 スクリーンショットを撮って確認しよう Viewの描画を確認したいわけですから、ユニットテストでは確認できません。 そこでまず思いついたのが、スクリーンショットを撮って、その画像で確認できたらいいんじゃないかというものでした。以前にEspresso+Spoonで自動的にスクリーンショットを撮るテストの話を見たのを覚えていたので、これを使えばいけそうと考えました。 問題が2つ しかしSpoonを使ってスクショを撮るには、WRITE_EXTERNAL_STORAGEパーミッションが必要になります。プロダクト側で必要なら問題ありませんが、そうでない場合はテストのためだけに不要なパーミッションを追加することになります。できればそれは避けたい。 また、スクショはActivityを起動してそれを撮影することになるわけですが、実際に対象のViewを表示するActivityがテストに適した作りになっているとは限りません。 例えばこのサンプルプロジェクトでも、MainActivityを使ってテストできなくもありません。Espressoを使ってボタンを押すようにすれば、カスタムViewの描画は切り替わります。しかしこのMainActivityの仕様だと、カスタムViewの上と下に線を描画した状態をテストできません。 つまり、実際に使うActivityとは別にテストのためだけのActivityが欲しいわけです。 ではそんなActivityをプロダクションに混ぜるのかという話になりますが、それも避けたい。 テスト用のProduct Flavorsを用意する そこでテスト用のプロダクトフレーバーを作成することでこれを回避しました。これもあまりスマートなやり方ではなく、できれば避けたかったのですが仕方ありません。 debugビルドにだけテスト用のパーミッション、Activityを含めるという方法もなくはないのですが、プロダクトフレーバーで切り分けてしまったほうが潔いかなと思ったのです。 テスト用のAndroidManifestとActivityさえ用意できれば、後は簡単です。 余談、androidTestに専用Activityを作ればいいんじゃないかという考え ちなみに私は最初、androidTest配下にテスト用のActivityを追加して、それ経由でテストすればいいんじゃないかと考えました。しかしそれはうまくいきません。 なぜなら、androidTestに配置したコードはテスト用のAPKにコンパイルされるからです。 私は今までずっと勘違いしていました。androidTestに書いたテストを実行したら、mainに配置してるテスト対象コードにテストコードを追加したAPKが作成されて、それでテストが実行されてるんだと思ってました。どうもそうではなくて、普通のAPKを単にテスト用APKで外部から操作してただけなんですね。 https://stackoverflow.com/questions/27826935/android-test-only-permissions-with-gradle 作り方 まずproductFlavorを追加します。サンプルでは普段使うやつをDefault、Viewのテスト用のものをUiTestとしました。ここではUiTestを追加するとして書いていますので、適宜読み替えてください。 まずapp/build.gradleにproductFlavorの設定を追加します。applicationIdSuffixはお好みで。 android { productFlavors { Default { } UiTest { applicationIdSuffix ".uiTest" } } // そのままだとUiTestReleaseもbuildVariantに追加されてしまうので、それに対処 android.variantFilter { variant -> if(variant.buildType.name.equals('release') && variant.getFlavors().get(0).name.equals('UiTest')) { variant.setIgnore(true); } } } EspressoとSpoonのセットアップ Espresso Spoon プロジェクトルートのbuild.gradleに追記。 classpath 'com.stanfy.spoon:spoon-gradle-plugin:1.2.2' app/build.gradleに追記。 apply plugin: 'spoon' android { defaultConfig { // 追加しないと多分テストがうまく走ってくれないと思います。 testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } } dependencies { androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', { exclude group: 'com.android.support', module: 'support-annotations' }) androidTestCompile('com.android.support.test:runner:0.5', { exclude group: 'com.android.support', module: 'support-annotations' }) androidTestCompile 'com.squareup.spoon:spoon-client:1.6.4' } プロダクトフレーバー用のディレクトリを作成 プロジェクトツールウィンドウのスコープをProjectに変更して、手動でディレクトリを作成します。(何か他にいい方法知ってれば教えてください)
Read full post gblog_arrow_right

画面をタッチして線を描く お絵かきアプリを作るための第一歩

今回の記事のサンプルコードは、GitHubで公開しています。 お絵かきアプリを作ろうと思って格闘中です。とりあえず線を描くだけでも学びがいろいろあったのでまとめておこうと思います。 Pathを使って描画するとカクカクする問題 線を描くにはPathを使うのがオーソドックスのようですが、何も考えずにパスを使った描画を行うと、線がカクカクしてしまいます。(サンプルコードのPathPaintView) path.lineTo(e.getX(), e.getY()); drawCanvas.drawPath(path, paint); これはなぜ起こるのでしょうか。 MotionEventが配信される間隔の問題 その理由はまず線をPathではなく点で描画してみると分かります。(DotPaintView) drawCanvas.drawPoint(e.getX(), e.getY(), paint); 描画される点がまばらになっています。このドットはonTouch()が呼ばれるタイミングで描画されています。このドットの間隔がタッチイベントがViewに伝えられているタイミングだということです。これはスクリーンをタッチした情報が、逐一間断なくonTouch()に渡されているわけではないことを意味しています。 Historical情報を利用する ではドットとドットの間のタッチイベントの情報は失われているのかというと、決してそうではありません。onTouchに渡されるMotionEventには、MotionEventが配信されていない時に生じた座標を保持しています。 その情報はMotionEvent.getHistoricalX()などで取得することができます。これを利用すれば、MotionEventの情報をより精細に取得することができます。(HistoricalDotPaintView) int history = e.getHistorySize(); for (int h = 0; h < history; h++){ drawCanvas.drawPoint(e.getHistoricalX(h), e.getHistoricalY(h), paint); } drawCanvas.drawPoint(e.getX(), e.getY(), paint); ドットの間隔が狭まりました。指をゆっくり動かせばキレイな線が描画できます。しかしこのHistorical情報にも限度があり、指を少しでも早く動かすとやはり間隔が空いてしまいます。 Historical情報を使ってPathによる描画を行う Historical情報を利用すれば、精度の高い座標情報を取得できることが分かりました。この座標情報をPathによる描画で利用してみます。(HistoricalPathPaintView) int history = e.getHistorySize(); for (int h = 0; h < history; h++){ path.lineTo(e.getHistoricalX(h), e.getHistoricalY(h)); } path.lineTo(e.getX(), e.getY()); drawCanvas.drawPath(path, paint); 単にpath.lineTo(x, y)で描画した時に比べると随分なめらかになりました。しかし、高速で動かしたらやっぱりカクカクしてしまうのは避けられません。なぜならpath.lineTo()による描画は、HistoricalDotPaintViewで描画した点と点の間を直線で結んでいるにすぎないからです。 これを解決するには、点と点の間をなめらかな曲線で結べば解決できそうです。 ベジェ曲線を利用する ベジェ曲線によりスムーズな線をひく方法はいろいろ考えられるでしょう。1つの方法としてこんなやり方ができます。(BezierPathPaintView) private void onTouchMove(MotionEvent e){ float midX = (previousX + e.getX()) / 2; float midY = (previousY + e.getY()) / 2; path.quadTo(previousX, previousY, midX, midY); previousX = e.getX(); previousY = e.getY(); } 自分で作っておきながら分かりやすく説明できないのですが、この処理のポイントは3つです。 前回のMotionEventで配信された座標点を記憶すること 前回の座標と今回の座標の中間点を計算すること 前回の座標を調整点とする、前回の中間点から今回の中間点までの2次ベジェ曲線を描く この方法では、正確にタッチした通りの線が描けるわけではないのですが、比較的簡単な処理でカクカクしない線を描くことができます。
Read full post gblog_arrow_right