20200301のAndroidに関する記事は12件です。

DroidKaigi2020の発表予定資料をまとめてみた

はじめに

中止に終わってしまったDroidKaigi2020ですが、
登壇予定の方々が発表予定だった資料を公開してくれています。
Twitterであれこれフォローしていてもうまく集めれていないですが、
ひとまずちょっとずつ集めていきます。

DroidKaigiのTimetable順に並べています。
タイトルはTimetableの概要にリンクを貼っています。

Day1(2020/2/20)

10:20-11:00

Jetpack時代のFragment再入門

資料

15:00-15:40

MDCの内部実装から学ぶ 表現力の高いViewの作り方

資料

16:00-16:40

Data Bindingのイロハ

資料

Day2(2020/2/21)

11:00-11:40

俺が今までやらかした失敗事例、やらかしそうになったヒヤリハット事例を紹介する

資料

詳解 WindowInsets

資料

13:00-13:40

Androidでもビジュアルリグレッションテストをはじめよう

資料

14:00-14:40

Interactive Canvasを使ってGUIを持ったActions on Googleを作る

資料

Scadeを使って「Swift」で始めるAndroidアプリ開発

資料

まとめ

他にTwitterやその他媒体で公開されている資料が見つかれば随時追加します。
また、コメントや編集リクエストで教えていただけると幸いです。
(公式のTimetableに記入してもらうのが本当は一番いいのですが...)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Android】 キーボードのレイアウト押し上げ設定が反映されない。

はじめに

「キーボードを出すとEditText下に配置しているviewが隠れて見えなくなっちゃう。」
っていうバグを直すのに、めちゃくちゃ時間かかった。
結局とても限定的な場面でだけ発生する事案だったんだけれど、ハマると全然気付けなさそうな原因だったので共有。

どんな状況?

EditTextの下に表示させている"0/1000"がキーボードで隠れているのがわかる。

これじゃ全くカウンターの意味がない。
キーボード表示中も見えるようにしておきたい。

調べると

めちゃくちゃ出てくる。

【参考】
https://qiita.com/bird_tummy/items/3709d607c4ec88ad9e71
http://furudate.hatenablog.com/entry/2013/07/10/012738
https://jiwashin.blogspot.com/2013/02/androideditview.html

詳しくは参考の記事を見て欲しいのだけれど、大まかにまとめるとやらなきゃいけないことは下記の2つ。
1. AndroidManifest.xmlの該当Activityにandroid:windowSoftInputMode="adjustResize"を設定する。
2. 該当ActivityのレイアウトのルートViewGroupにandroid:fitsSystemWindows="true"を設定する。

参考にして修正してみたのだけれど、依然直らず。
別に原因があるんじゃないかと考えた。

画面よくみてみると

もう一回キーボード出した時の画面をよく見てみる。

なんかToolbarも消えてない?
っていうかそれよりも、StatusBarが出てない。
画面下にばっか目がいってたけど、そこじゃない気がしてきた。

コード見てみると

この画面に行く前に、StatusBarを隠す処理を実行していた。

private fun hideStatusBar() {
    val imm = activity?.getSystemService(Cotnext.INPUT_METHOD_SERVICE) as InputMethodManager
    imm.hideSoftInputFromWindow(view?.windowtoken, InputMethodManager.HIDE_NOT_ALWAYS)
    view?.clearFocus()
}

試しにこの画面に入る時にStatusBarを出す処理を入れてみる。

private fun showStatusBar() {
    activity?.let {
        it.window.clearFlags(WindowManager.layoutParam.FLAG_FULLSCREEN)
        val decor = it.window.decorView
        decor.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_IMMERSIVE)
    }
}

すると

でた。
色がダサいのは気にしないで欲しい。

まとめ

  • EditText使う時にレイアウト下部に表示しとかなきゃいけないViewがある場合はキーボードで隠れていないか注意が必要。
  • AndroidManifest.xmlとレイアウトにキーボードの設定をしていても、StatusBarを隠す処理を入れていると設定が反映されない。

結局ちょっと深掘りして調べてみたけど、きちんとした原因まではわからなかった。
わかる人がいたら教えてほしい。

これにて一件落着。めでたし。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(10)

前回の続きです。

今回の目標

予告はFirebaseをつかった認証とDatabaseとしていましたが、ちょっと方針転換で、CI(継続的インテグレーション)をやります。
テストが割と長くなってきたので、サーバーにお任せしたいな、と。
それと、apkファイルへの署名についてもやっていきます。

CIとは

CIとは、継続的インテグレーションです。Continuous Ingegrationの略です。
ざっくばらんに言うと、ビルドとテストを自動化してどっかクラウドとか別サーバーに繰り返しやらせましょうねってことです。
やらせる契機は、ブランチへのpushだったり、PR(プルリク)だったり、masterへのマージだったり、いろいろ指定できるものですが、今回は、masterを除くブランチへのpushで毎回ビルドとデバッグテストを行うというのをやってみようと思います。

1. CIツール

いくつかのCIツールについて紹介します。
今回やるのは、

  • Github Actions

ですが、他にも、

  • Jenkins
  • CircleCI
  • TravisCI
  • bitrise
  • Azure Pipelines
  • GitLab CI/CD
  • Bitbucket Pipelines

などがあります。

クラウドで出来るものと、オンプレミス(自分でサーバーやローカルマシンに環境構築が必要)なものとありますが、いずれも、オープンソースならば利用が無料なところが多いです。ただ、単にソースコードを公開しているだけではだめな場合もあるので、よく規約や料金プランを確認して下さいね。

(2) CIツールでやること

基本的には、CIは以下のことを行うことが目的です。

  • ビルド
    • 生成物(Androidの場合、apkファイル)の保存
  • テスト(Unit Test/自動化されたUI Test)
    • 結果レポートの保存と閲覧

ビルドは、Androidの場合、デバッグビルドだけやるのを紹介している記事がほとんどです。
これには理由があります。

それは、リリースビルドのapkには「署名」が必要だからです。(実際にはデバッグビルドでも必要ですが、それについては後述)

で、署名に必要な情報をそのままリポジトリにアップロードしてしまうと、パスワードを平文で公開している状態となって非常に危険なので、通常、署名関連の情報はリポジトリには登録しないという運用を取ります。
そうすると、CIツールの方で、今度は、署名に必要な情報を参照することが困難になります。(CIツールは基本的にリポジトリをインターネット経由で参照します。リポジトリに上がっていないファイルは、どうやったって使えないですよね)

ということで、CIという範疇では、デバッグ用apkが出来ればいいだろうということで、そこまでしかやっていない紹介記事が多いのです。

リリース署名を行う、というのは、CIの先のステップとも言えるので、確かに十分ではあるんですよね。

ただ、今回せっかくCIツールについて調べるので、「リリースapkの署名まで自動化したいなあ」という思いがあり、一緒に調べることにしました。

ということで、CIツールのお試しに入る前に、apkへの署名についてもここでやってしまいましょう。

アプリ署名について

Androidアプリは、最終的に以下のどちらかの形式でアプリファイルを配布します。

  • apk (すべての端末向けの1ファイル)
  • abb (Android App Bundle/端末仕様毎に必要なリソースを分けて配布)

どちらにしても、証明書で署名する必要があります。

また、最近は、Googleがアプリに署名してくれるGoogle Play アプリ署名というのが推奨になっていて、これは証明書をGoogleが保管してくれるというものなのですが、とはいえ、アップロード鍵としてやはり証明書を作って署名する、という手順は結局変わらず必要です。
違うのは、アップロード鍵はGoogleさんに「失くした!」と泣きつけば、再発行できるというメリットがあることですね。
以前の、「開発者が証明書を管理して署名する」だと、証明書が行方不明になると万事休すでした。二度と同じアプリをアップデート配信できなかったんですね。
そのリスクがなくなるという意味では、このGoogle App Signingというのを今後は使って行くべきでしょうね。

アップロード鍵での署名にせよ、アプリ署名にせよ、やることは同じです。
コマンドラインから作ることも出来ますが、ここではAndroid Studioでまず署名できることを確認しましょう。ただ、バージョン3.5から妙なエラーが出るんだけど、今のところ無視しても大丈夫なようです。

(1) debugビルドへの署名

実は、debugビルドのアプリも、署名されてるんです。AndroidSDKが自動的にデバッグ証明書で署名しています。
デバッグ証明書(keystore)はデフォルトでは、下記の場所にあります。

  • Mac/Linux
  ~/.android/debug.keystore
  • Windows
  C:¥Users¥<user>¥.android¥debug.keystore

デバッグ用証明書は、開発機毎に異なります。証明書が異なると、上書きインストールできません。従って、チームなどで開発用のスマホ実機を使い回していたりすると、他の人がデバッグ実行した後のスマホで、別の人がデバッグ実行しようとすると、アンインストールが一度必要になります。
また、今回、CIツールでapkをビルドし、それをダウンロードして使うことを考えると、クラウド型のツールを使うと、毎回デバッグ用署名が違うことになってしまいます。
その度に、いちいち一度アンインストールしてから再インストールしなければならなくなり、ちょっと面倒そうです。
なので、デバッグ証明書も作って、自前で署名するステップを入れておきます。

1.debugビルドのパッケージ名を変える

さて、デバッグ証明書と、リリース用の証明書は変えなければなりません。となると、それぞれのapkの署名が一致しなくなってしまいます。
で、何が困るかというと、debugビルドのアプリがインストール済の端末に、releaseビルドのアプリを上書きインストール出来ないということになります。
(署名が一致しないと同じアプリと見なされない)

回避する方法は、

  • debugビルドとreleaseビルドで同じ署名を使う
    • =上書きインストール出来るようにする
  • パッケージ名を変えて上書きインストールにならないようにする
    • =別のアプリとして認識させる

のどちらかになります。
で、個人的には、両方同時インストールできる後者のパターンの方が好きです。
ただし、アプリ内課金などがある場合には使えない手段なので、用心が必要ですが。
今回は、アプリ内課金は今のところ予定していないので、別パッケージにして、同時にインストールしておけるようにします。
テストを流すとデータ消えちゃうし、releaseビルドのアプリを本格的に使ってるとそれだと問題ですから^^;

app/build.gradleに以下の記述を追加します。

app/build.gradle
android{
   ...

   buildTypes {
        debug{
            applicationIdSuffix ".debug"
            minifyEnabled false
        }
       ....
    }
    ....
}

これで、debugビルドの時のパッケージ名には、suffixとして".debug"が付くようになります。
つまり、jp.les.kasa.sample.mykotlinappがデフォルトのパッケージ名なので、デバッグ時にはjp.les.kasa.sample.mykotlinapp.debugとなります。

2.アプリ名をビルドタイプ別に変更する

それと、両方同時インストール出来ると、今度は、「どっちがどっちのアプリか分からなくなる」問題が発生します。
分かりやすくするため、私はランチャーが表示するアプリ名を変更することをよくやります。

app/build.gradle
buildTypes {
        debug{
            resValue "string", "app_name", "(d)歩数計記録アプリ"
            ...
        }
        release{
            resValue "string", "app_name", "歩数計記録アプリ"
            ...
        }

これは、文字列リソースをbuild.gradleで定義する方法です。

res/values/strings.xml
   <string name="app_name">PedometerSample</string>

こんな感じでapp_nameという文字列リソースを定義していたと思いますが、これをbuild.gradleで定義できるんですね。便利です。
以前(大昔)は、debugビルドとreleaseビルドでリソースを分ける場合、app/src/debug/res/values/strings.xmlapp/src/main(or release)/res/values/strings.xmlとでリソースファイルを別に用意する必要がありましたが、gradleに移行したことでこういうことが出来るようになりました。リソースファイルをビルドタイプ別に用意するのは、同時に比較するのが難しく、ファイル数が増えて管理が煩雑になる、という問題点がありました。
gradleファイルに書いてあれば、debugビルドとreleaseビルドでの値の違いが、とても分かりやすく、管理も楽になりました。

ということで、リソースファイルのapp_nameは削除して大丈夫です。

Sync Nowしておきましょう。

3.デバッグ証明書を作成する

AndroidStudioから作成します。

  • メニューの[Build]-[Generate Signed Bundle/APK...]を選択
    qiita10_01.png

  • 続くダイアログは、いったんデフォルトのままで[Next]をクリック
    (証明書作成までしかしないので、今は無関係)

qiita10_02_01.png

  • [Create New]をクリック

qiita10_03.png

  • フォルダアイコンをクリック

qiit10_02_04.png

  • 証明書を置くフォルダ(app/下)を選び、ファイル名は debug.jksとする(ファイル名は任意。デバッグ用だと分かりやすくしておく)
  • [Save]をクリック

qiita10_04_01.png

  • 以下の内容でセットする
設定項目 設定内容
鍵ストア・パスワード android
キー・エイリアス androiddebugkey
鍵パスワード android
First and Last Name Android Debug
Organizatoin Android
Country Code US

以前は、AndroidStudioが署名するのと同じパスワード、エイリアスで無いとデバッグ実行できませんでしたが、最近はその必要は無く、何でも良くなっています。
なので上記の通りに設定する必要は、本来はないです。

ただ、このデバッグ証明書のパスワードなどの情報はソース管理のリポジトリに上げてしまいますので、全世界に公開されます。デバッグ証明書で署名したAPKはPlayストアに登録できないのですが、パスワードやエイリアス情報を見ていると思われます。なので、ここは公開されているデバッグ鍵と同じ情報にしておくのが無難でしょう。

  • [OK]をクリック
    • 以下のエラーが表示されるが、今は無視して大丈夫

qiita10_09.png

証明書が指定したフォルダに作成されます。
証明書が出来たら、今はここまでで良いので、[Cancel]をクリックして閉じます。

4.デバッグ署名を設定する

  • メニューの[File] - [Project Structure...]を選ぶ

qiita04_03.png

  • 左側のパネルで[Modules] を選び、右側のタブで [Signing Configs]を選ぶ

qiita10_04_04.png

  • 先ほど作成したdebug.jksのパスを選ぶ
  • パスワード、エイリアス情報を入力して [Apply]をクリック

qiita10_04_05.png

  • Syncが終わるのを待って、左側のパネルで[Build Variants]を選ぶ
  • 右側の[Build Types]タブで debugを選び、下にスクロールして、Signing Configの右のドロップダウンから、$signingConfigs.debugを選ぶ

qiita10_04_06.png

  • OKをクリック

Gradle Syncが終わったら、app/build.gradleを開いてみましょう。

こんな項目が追記されているはずです。

app/build.gradle
    signingConfigs {
        debug {
            storeFile file('debug.jks')
            storePassword 'android'
            keyAlias = 'androiddebugkey'
            keyPassword 'android'
        }
    }

storeFileのパスは、絶対パスだとチームの他の人やCIツールで一致しなくなって困るので、app/からの相対パスに変更しておきます。

また、buildTypesのdebugビルド内にも、以下のような設定が追記されています。

app/build.gradle
    buildTypes {
        debug{
            ...
            signingConfig signingConfigs.debug
        }

これでデバッグ実行してみて下さい。今までと署名が変わったので、上書きインストールが出来ないはずです。ここは素直にアンインストールしてからインストールしましょう。
インストールが出来てアプリが起動すればデバッグ署名はOKです。

(2) releaseビルドに署名する

1. 証明書を作ってビルドする

今度は証明書を作ってビルドまで、全部実行してみます。

  • AndroidStudioのメニューから、[Build]-[Generate Signed Bundle/APK...]を選択

  • [APK]を選ぶ

※最終的には App Bundleにするかどうかは任意です。

qiita10_02.png

  • [Create New]を選ぶ

  • app/下に任意のファイル名(例:release.jks)で作成する

qiita10_05.png

  • 任意の情報で設定する

qiita10_06.png

ストアパスワード、キーエイリアス、エイリアスパスワード、国名(JP等)が必須項目です。
それ以外はなくてもいいですが、後で証明書を差し替えるのは大変なので、個人であっても氏名と地域情報は入れておいた方が良いでしょうね。

  • [OK]をクリック

  • 以下のエラーが表示されるが、今は無視して大丈夫

qiita10_09.png

  • (任意)[Remember passwords]にチェックを入れて、[Next]をクリック

※ [Remember passwords]にチェックを入れても、パスワードは覚えておきましょう

qiita10_07.png

  • [Build Variants]のreleaseを選び、V2 (Full APK Signature)にチェックを入れる

qiita10_08.png

※V1は、古い開発ツール時代から署名していたアプリ用のオプションです。

  • [Finish]をクリック

ビルドが始まります。しばらく待ちましょう。
成功しましたか?

APKは、app/release下に出力されています。

qiita10_10.png

2. 署名情報の設定ファイルを作成する

さて、releaseビルド用の証明書のパスワードやらをbuild.gradleに書いてしまうわけにはいきません。他の人がアプリを改ざんしほうだいになってしまいます!

色々やり方はあると思いますが、Googleさんも公式に推奨している、Gradleのプロパティファイルにいったん逃がす方法を応用して使おうと思います。

  • プロジェクトルートのディレクトリにkeystore.propertiesというファイルを作り、各情報を設定する
keystore.properties
    storePassword=ストアパスワード
    keyPassword=キーパスワード
    keyAlias=キーエイリアス
    storeFile=release.jks

ストアパスワード、キーパスワード、エイリアスは、先ほどリリース用証明書を作ったときに使ったものを入力して下さい。
(まさかもうパスワード忘れたとかないよね〜)

  • app/build.gradleに以下を追記する
app/build.gradle
apply plugin: xxx

def keystorePropertiesFile = rootProject.file("keystore.properties")

android {
    ...

    signingConfigs {
        debug {
            ...
        }
        release {
            if (keystorePropertiesFile.exists()) {
                def keystoreProperties = new Properties()
                keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
                keyAlias keystoreProperties['keyAlias']
                keyPassword keystoreProperties['keyPassword']
                storeFile file(keystoreProperties['storeFile'])
                storePassword keystoreProperties['storePassword']
            }
        }
    }

keystore.propertiesファイルが無かったときにビルドが失敗しないようにしてあります。

  • app/build.gradlebuildTypes{}に以下を追記する
app/build.gradle
    buildTypes {
        debug {
            ...
        }
        release {
            ...
            if (keystorePropertiesFile.exists()) {
                signingConfig signingConfigs.release
            }
        }
    }

こちらも、keystore.propertiesファイルが無かったときにビルドが失敗しないようにしてあります。

keystore.propertiesファイルが無かったときは、未署名のapkが出力されます。

これでビルドが通るかどうかは、ちょっと後回し。

3. 無視ファイルの設定変更

まず、output.jsonがソースコード管理に入ってしまうと面倒なので、気になる人はプロジェクトルートの無視ファイル(.gitignore)に追加しておきましょう。

※Macの人は、Finderでcommand + shift + .とすると隠しファイルが表示されます。

output.json

それから、デバッグ用証明書はコード管理に追跡させたいですが、リリース用証明書と、そのパスワード情報などが書かれたkeystore.propertiesはGithubで公開されては困ります。
(Privateリポジトリなら上げてしまってもいいかも知れませんが、万が一の漏洩リスクを考えると、上げない方が良いように思います。)

ということで、これらについても.gitignoreファイルに追記します。

release.jks
keystore.properties

ファイルを保存して、git statusなどで見てみて下さい。
無視する設定にしたファイルが表示されていないことをしっかり確認してから、コミットしましょう。

コマンドラインからの実行

コマンドラインからビルド、テストが出来るのを確認します。なぜなら、CIツールではこれらを使うからです。先ほどのリリース証明書での署名が成功するかのテストにもなります。

(1) ビルド

  • debugビルド
    $ ./gradlew assembleDebug

    • 出力は、app/build/outputs/apk/debug
  • releaseビルド
    $ ./gradlew assembleRelease

    • 出力は、app/build/outputs/apk/release
    • AndroidStudioから[Generate Signed Bundle/APK...]でやったときに出力されたフォルダとは違うので要注意
  • 両方ビルド
    $ ./gradlew assemble

(2) Lintチェック

言語的、あるいはAndroidのお作法的推奨事項を持っているか、無駄な変数、リソースが無いかのチェック等をしてくれます。

  • debugビルドのLintチェック
    $ ./gradlew lintDebug

  • releaseビルドのLintチェック
    $ ./gradlew lintRelease

  • 両方Lintチェック
    $ ./gradlew lint

ログに出るので分かると思いますが、レポート結果の出力先は app/build/reports/です。
結構いろいろ出てますね(汗)
対応するかどうかはお任せします。

(3) UnitTest

JUnitテスト、Robolectricテストが実行されるテストです。

  • debugビルドのUnitTest
    $ ./gradlew testDebugUnitTest
    • 結果は、app/build/reports/tests/に出力

./gradlew testでもUnitTestが実行できるのですが、これはreleaseビルドのUnitTestまで実行してしまいます。今回、releaseビルドは事情があって実行できないので(動かしてみると理由が分かると思います)、debugビルドに限定するコマンドで実行します。

注意点としては、一度テストをした後は、コードが変わっていないと、cleanするまでテストが実行されません。
クラウド型のCIならば、恐らく毎回clean状態なので大丈夫だとは思いますが、Jenkinsなどで行う場合は、cleanさせる設定を入れておく必要があるでしょうね。

なお、./gradlew checkで、Lintチェック + testが実行出来ます。
また、./gradlew buildで、ビルド + Lintチェック + testが実行出来ます。

リリースビルドでUnitTestが行われてしまっても大丈夫なときは、buildコマンド1つが楽かも知れません。

(4) Instumentationテスト

今回はエミュレーターで実行してみて下さい。ほとんどのCIでは必然的にエミュレーターを使うことになりますので。

API Level 28 のエミュレーターを作り、起動しておきます。
(※今現在、compileSdkVersiontargetSdkVersion28にしているから。29に上げないと2020/8/3以降はPlayストアにアプリを登録することが出来ないので、要注意。なおアップデートの場合は、11/2以降不可になります)

初期設定が終わるのを待ちましょう。

その後、以下のコマンドでInstumentationテストが実行されます。

$ ./gradlew connectedCheck

このコマンドは、debugビルドだけみたいです。
結果は、app/build/reports/androidTests/connected/に出力されます。

全部通過していると気持ちいいですね。

各タスクの実行コマンド、結果の保存場所や見方が分かったところで、いよいよ、CIツールの出番です。

Github Actions

GithubがいよいよCIに乗り出してリリースされた機能です。
中身はAzureで動いているようですね。そういや、Microsoftに買収されたんだっけか・・・

パブリックリポジトリはGithub Actionsの利用は無料、と言っているので、自由に使わせて貰うことにします。
プライベートリポジトリの場合は、いろいろと制約、制限、課金があるようですので、ご注意下さい。

(1) ActionsからWorkflowを作る

早速、workflowを作ってみましょう。
Githubの該当のリポジトリページを表示し、masterブランチになっているのを確認し、上のタブ(ちょっと分かりづらいですが)の[Actions]を選びます。

※後で無視するブランチなどを追加していきますが、その際、いったんmasterブランチに入っていないとだめみたいなので、まずはmasterに追加する必要がありました。

qiita10_20.png

1. デバッグビルド

まずはデバッグビルドが出来るWorkflowを作成します。

  • [New workflow]をクリック
  • 下にスクロールして、[Workflows for Python, Maven, Docker and more...]をクリック

qiita10_12.png

  • Android CIをクリック

qiita10_13.png

こんなファイルが表示されるはずです。

android.yml
name: Android CI

on: [push]

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2
    - name: set up JDK 1.8
      uses: actions/setup-java@v1
      with:
        java-version: 1.8
    - name: Build with Gradle
      run: ./gradlew build

これは、[push]がされたら、ubuntu上で、チェックアウトしてきて、JDK1.8の設定をして、gradlew buildコマンドを実行する、という設定になっています。

relaseビルドでのUnitTestが実行されると困るので、まずはdebugビルドだけするように、run:のコマンドの部分を、./gradlew assembleDebugに変更します。

android.yml
    - name: Build with Gradle
      run: ./gradlew assembleDebug

ページ右上にある、[Start commit]をクリックしてみて下さい。

qiita10_14.png

コミットメッセージは任意で、[Commit directly to the master branch]を選択して、[Commit new file]をクリックします。

早速Workflowが流れるはずですが、私の場合、masterブランチは空っぽなので、ビルドエラーになります。
すでにmasterブランチにコードがある方は、デバッグビルドが流れて成功するはずですね。

2. masterブランチをローカルにpullする

masterブランチをローカルにpullしてきます。
(どうでも良いですが私はGitのGUIツールでは、Source Treeが好きです)

これからメインにpushしていきたい開発ブランチ(私の場合、feature/qiita_10)に、マージしてpushします。

すると、pushしたブランチで、Workflowが走るはずです。
今度は私の環境でもビルドが成功しました。

(2) 無視するブランチを設定する

とりあえず、私のように、masterが空だったりするときや、実験用などで毎回Workflowが動くと困るようなブランチがある時のために、特定ブランチへのpushは無視するという設定が出来ます。

再び、masterブランチ上の.github/workflows/android.ymlを編集します。

pushの階層下に、branches-ignore:で追加できます。

name: Android CI

on:
  push:
    branches-ignore:
      - 'master'
      - 'feature/qiita_10_sub/**'

私の場合、今後、他のCIツールをお試しするときのブランチをfeature/qiita_10_sub/xxxみたいに作っていく予定なので、それも設定しました。このように、正規表現が使えます。

これとは逆に、特定のブランチのみビルドしたい場合(devブランチのみで回すなど)は、branches:で指定できます。

先ほどと同じく、masterブランチにそのままコミットし、ローカルにpullしてきて開発ブランチにマージします。

このままpushしてもいいですが、せっかくなので次の設定も追加してからにしましょう。

(3) APKをアーカイブする

ビルドしたAPKファイルを、アーティファクトとして保存してダウンロード出来るようにしましょう。

ここからは、開発用ブランチで行って大丈夫です。

github/workflows/android.yml
    - name: Archive Debug Apk
      if: success()
      uses: actions/upload-artifact@v1
      with:
        name: debugApk
        path: app/build/outputs/apk/debug/app-debug.apk

インデントが重要なので、気をつけて下さいね。
if: success()は、前のタスクが成功したときのみ実行するという条件です。
actions/upload-artifact@v1というアクションを使うと、成果物(artifact)をアップロードできます。
ここでは、debugApkという名前で、path:で指定したファイルをアップロードしています。

ymlファイルを保存して、コミットし、pushしてみましょう。
ビルドが成功して、Workflowのページを見ると、Artifactsの所に、apkファイルへのリンクがあるはずです。クリックするとDL出来るかと思います。DLしてくるのはZipファイルになっているので、解凍すると、中にapp-debug.apkが入っています。

上書きインストールをしてみて下さい。
デバッグ用証明書が共通化されたので、他のマシンでビルドしたapkでも上書きインストールが出来るはずです。

(4) テストの設定を追加する

次はテストが実行されるようにWorkflowを編集します。

1. Checkタスクを追加

Lintと、UnitTest/ReobolectricTestが実行されるようにタスクを追加します。
ymlファイルの一番下に以下を追記します。

android.yml
    - name: Check
      if: success()
      run: ./gradlew lint testDebugUnitTest

    - name: Archive results
      if: always()
      uses: actions/upload-artifact@v1
      with:
        name: test-reports
        path: app/build/reports

Checkという名前のジョブで、linttestDebugUnitTestのgradleコマンドを実行しています。
Archive resultsという名前のジョブで、Lint、テストの実行結果レポートをアップロードしています。

コミットしてpushしてみましょう。
Workflowのジョブリストに、Checkというのが追加されているでしょうか?

終わると、Artifactのところに、test-reportsというのが出来ているはずです。Zipファイルをダウンロードして、解凍して中身を確認しましょう。

ローカルでtestDebugUnitTestとしたときと同じレポートが見られるはずです。

2. 仮想マシンを変更する

さて、エミュレーターでのInstrumentationテストもやって欲しいですよね。
これが出来ると、フルテストを実行中、スマホが使えなくなってしまうことも無くなります。
テスト中に電話が掛かってきたりpush通知で画面占有されたりして「あああテストが失敗やり直し・・・」なんてことも無くなります。

Github Actionsにはちゃんとそのためのアクションを既に作って公開して下さっている方がいるのですが、1つだけその前に直す必要があるところがあります。

仮想マシンのOSタイプです。
デフォルトだと、Ubuntu(Linux)になっていますが、エミュレーターは、HAXMというのを使わないととてもじゃないけど重くて、これはUbuntu向けにはどうやら今は用意されていないようなのです。
なので、ymlファイルのruns-onにある設定を、MacOS用に変更します。

android.yml
    runs-on: macOS-latest

これだけです。

※本来、WindowsでもエミュレーターはHAXMで動かせるはずですが、参考記事はみんなmacOSを指定してました。理由までは追ってないですが、Github側が用意しているWindows仮想マシンが、HAXMに対応していないのかも知れません。誰か理由をご存じの方や、試してみて動いたor動かなかった等あったら教えて下さい!

3. エミュレーターテストのタスクを追加する

Github Actions android emulator test等でググると、いくつか情報が見つかりますが、バージョンが正式リリースされていてメンテも継続されていそうな、こちらを使わせて頂くことにします。

Android Emulator Runner
https://github.com/marketplace/actions/android-emulator-runner

ymlファイルに以下を追記します。追加するのは、CheckジョブとArchive resultsジョブの間です。

android.yml
    - name: Android Emulator Runner
      if: always()
      uses: reactivecircus/android-emulator-runner@v2
      with:
        api-level: 28
        profile: Nexus 6
        script: ./gradlew connectedCheck

profileに大きめの画面のものを設定しないと、どうやら非表示のウィジェットが出来るようでテストが失敗するものがあるので、ここではNexus 6を指定しました。

ただ、「$ANDROID_HOME/tools/bin/avdmanager listでとれる端末リストの名前を指定できる」と書いてあるのですが、Pixelを指定してもそんなAVD無いと言われてしまいました。
なので、ここは無難にサンプルに上がっていたNexus 6にしています。
他に何が使えるかは、試していません。

ymlファイルをコミットしてpushしましょう。
エミュレーターテストは時間かかるので、その間に他のことをして待ちましょう。
自分のスマホや開発マシン(PC)は空いたので、何でも出来ますね(笑)

(5) 通知を設定する

Workflowが終わったら、どこかに通知して欲しいですよね。
Slackとか、チャットワークとか。
今回は、Slackにしてみます。

1. SlackにWebHookアプリを追加する

Slackに既に利用中のワークスペースが無い場合は、自分用に作っておきましょう。
この手順は割愛します。

SlackのIncoming WebHookアプリのページへ行きます。
https://slack.com/apps/A0F7XDUAZ-incoming-webhooks

  • [Slackに追加]をクリック

qiita10_80.png

  • 投稿したいチャンネルを選ぶ
  • [Incoming Webhook インテグレーションの追加]をクリック
  • Webhook URLの下にある[Copy Url]をクリック

その他の設定は任意です。

  • [設定を保存する]をクリック

これでSlackの準備は出来ました。

2. GithubにWebhook URLを設定

Githubに先ほどコピーしたWebhook URLSecretsとして保存します。

Githubの自分のリポジトリページへ行きます。

  • タブ[Settings]を選ぶ(分かりにくい)
  • 左側の[Options]メニューの中から、[Secrets]を選ぶ

qiita10_82.png

  • [Add a new secret]をクリック
  • Nameに任意の名前を入力
  • Valueに先ほどコピーしたWebhook URLを貼り付ける

qiita10_83.png

  • [Add secret]をクリック

準備はこれで終わりです。

3. Actionsに追加

Slack通知してくれるGithub ActionはGithub Marketplaceに多数上がっていて、いくつか試したのですが、ほとんどがLinux(Ububntu)上でしか動かせないものでした。
エミュレーターテストのため、macOSにしていますから、IncomingWebhookを使い、かつmacOSで動かせるものを探して、ようやく見つけたのが、こちらです。

https://github.com/marketplace/actions/slatify

作者様、ありがとうございます!
日本の方のようです。Qiita記事からたどり着きました。最後に参考リンクを貼ってありますので興味ある方はご覧になってください。

ということで、上記のページのサンプルを参考に、ymlの最後に以下を追記しました。

android.yml
    - name: Slack notification
      uses: homoluctus/slatify@master
      with:
        type: ${{ job.status }}
        job_name: '*Build and DebugCheck*'
        channel: '#general'
        url: ${{ secrets.SLACK_WEBHOOK_URL }}

${{ job.status }}で、ジョブの完了ステータスを教えてくれます。
Slackへの投稿は、見た目はこんな感じになりました。

qoota10_88.png

エラーだとこんな感じ。

qiita10_85.png

良い感じです!
これで、テストが終わったか何度もPCを覗きに来る必要がなくなりました。

※1 Slackへの通知は、単純にcurlコマンドを書いても出来ないことはないので、「curlコマンド大好き」って方は、それでも良いでしょう(笑)
私はどなたか作ってくれて無いかなーと半分意地になって探しました^^;

※2 ジョブを分けて、そちらのジョブではOSにUbuntu使うようにすることで、Linux(Ububntu)上でしか動かせないアクションでも動かせます。qiita_10_sub/multi_jobs_os_changeブランチにslack.ymlというのをサンプルで入れてありますのでご参照下さい。
https://github.com/le-kamba/qiita_pedometer/blob/feature/qiita_10_sub/multi_jobs_os_change/.github/workflows/slack.yml
でも、マシンのビルドや設定が走って遅いので、出来るなら同じOSの1つのマシンで動かしたいですよね。

(6) ステータスバッジをつける

1. ステータスバッジをREADMEに追加する

せっかくなので、README.mdファイルにステータスバッジをつけようと思います。
ステータスバッジとは、CIツールなどでのビルドやテストが通っていることを示す画像のことです。
大きなGithubプロジェクトのページに行くと、よく貼ってあると思います。
いろんなCIツールがバッジを提供しています。

Github Actionsでは次の手順でつけられます。

  • ActionsのページでバッジをつけたいWorkflowを選ぶ
  • 右上の[Create status badge]というボタンをクリック

qiita10_99.png

  • ブランチを選ぶ(任意)
  • [Copy status badge Markdown]をクリック

qiita10_100.png

  • README.mdファイルを開く
    • Github上で開いてしまっても可
  • 先ほどコピーしたテキストを貼り付けて保存
    • 場所は任意だけどだいたい先頭に置かれていることが多い
  • コミット
  • push(ローカルで編集した場合)

こんな風に表示されます。

qiita10_101.png

なんかちゃんと運用されているプロジェクトっぽくなりました(笑)

2. トリガーを無視するファイルを設定する

さて、README.mdだけを編集してコミット、pushしたときに、Workflowが動いてしまいますね。
ビルドには全く関係ないファイルの更新で、テストまで走ってしまうのはかなりの無駄です。
そこで、トリガーを無視するファイルの設定を行っておきます。
それには、paths-ignoreを使います。

android.yml
on:
  push:
    paths-ignore:
      - '**.md'
    branches-ignore:
      - master
      - 'feature/qiita_10_sub/**'

ドキュメント(マークダウンファイル(.md))はすべて無視するようにしました。
これで、コミットファイルが一致するものしか無い場合は、Workflowが起動しないはずです。

※確認するには、先にandroid.ymlをコミットしてpushした後、README.mdを編集してコミット&pushしてみてください。

(7) リリースビルドを追加する

さて、リリースビルドも一緒に出来るようにしていこうと思います。
ただ問題は、署名ファイルです。
Githubに上げるわけにはいきません。
先ほどのSecretsを使って上手くいかないかなあと思っていたのですが、いわゆる「秘密ファイル」の登録は出来ないようで、悩みました。

解決策としては、以下の2つくらいしか無さそうです。

A. Sign Android Release Actionを使ってみる
B. 以下の内容を参考に、証明書を暗号化したファイルをコミットして使う
https://help.github.com/ja/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets

せっかく作って下さっているので、Aを試してみようと思います。

Sign Android Release ActionのMarketページによると、以下の値が必須だと書いてあります。

変数名 内容
releaseDirectory 署名したapkの出力フォルダへの相対パス
signingKeyBase64 証明書ファイル(.jksファイル)をbase64エンコードした文字列
alias 証明書のエイリアス
keyStorePassword 証明書の鍵ストアパスワード
keyPassword 証明書の鍵パスワード

releaseDirectory以外は、すべてGithubのSecretsに保存して使います。
signingKeyBase64の用意が必要ですね。でも親切にコマンドを書いてくれているので、そのまま実行させましょう。Macだと多分デフォルトで入っているはず。Windowsのかたはすみません、自分で調べて入れて下さい。

ターミナルなどでrelease.jksファイルがあるフォルダに移動し、以下のコマンドを叩きます。

$ openssl base64 < release.jks | tr -d '\n' | tee release.jks.base64.txt

release.jks.base64.txtが同じフォルダに出力されているので、ファイルを開いて中身をすべてコピーし、Secretsに登録します。

Name : SIGNING_KEY
Value : コピーした文字列を貼り付け

登録したら、release.jks.base64.txtがコミットされてしまわないよう、ファイルは削除しておきましょう。

後はkeystore.propertiesに設定した情報を参考に、Secretsに1つずつ設定すればOK。

Name Value
ALIAS 証明書のエイリアス
KEY_STORE_PASSWORD 証明書の鍵ストアパスワード
KEY_PASSWORD 証明書の鍵パスワード

android.ymlに以下のように追加します。場所はデバッグ用apkをアーティファクトに上げているタスクの後で良いでしょう。

android.yml
    - name: Sign Release Apk
      if: success()
      uses: r0adkll/sign-android-release@v1
      with:
        releaseDirectory: app/build/outputs/apk/release
        signingKeyBase64: ${{ secrets.SIGNING_KEY }}
        alias: ${{ secrets.ALIAS }}
        keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
        keyPassword: ${{ secrets.KEY_PASSWORD }}

ファイルをコミット、pushして、Actionを流します。
署名が成功しましたか?

keystore.propertiesが完全にローカルビルド用になっちゃいますが、設定としてはすべてSecretsで対応でき、シンプルで良いですね。

Bの方法は、証明書ファイルとkeystore.propertiesを暗号化した上でGithubに上げてしまい、Actionsのタスクの中でそれらを復号化して使う、ということになるかと思います。
結構煩雑そうなのでここではやりませんが、せっかく作ったkeystore.propertiesを活用できる方法かなとも思うので興味があればやってみても良いかも知れません。

(8) リリースAPKをアーカイブする

さて、署名が出来たら、apkをアーティファクトに上げれば、実行後にActionsのページからダウンロードできるようになります。
先ほどのSign Android Release Actionが、

Outputs
signedReleaseFile
The path to the signed release file from this action

と、署名済みファイル名を出力してくれているので、こんな風にアクセスして使えます。

   path: ${{ steps.<id>.outputs.signedReleaseFile }}

<id> は、タスクにidを付けておいて、そのタスクの出力を使う、という構文です。
なのでまず、署名タスクにidを付けます。
その上で、アーティファクト部分のタスクを書きます。

android.yml
   - name: Sign Release Apk
      if: success()
      uses: r0adkll/sign-android-release@v1
      with:
        releaseDirectory: app/build/outputs/apk/release
        signingKeyBase64: ${{ secrets.SIGNING_KEY }}
        alias: ${{ secrets.ALIAS }}
        keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
        keyPassword: ${{ secrets.KEY_PASSWORD }}
      id: sign-apk # 追加

    - name: Archive Release Apk
      if: success()
      uses: actions/upload-artifact@v1
      with:
        name: releaseApk
        path: ${{ steps.sign-apk.outputs.signedReleaseFile }}

コミット、pushして、Actionを流します。
署名とアーティファクトのアップロードが成功しました。

ただ、ここで出来た署名済みapkをDLしてから、ふと思いました。

publicなリポジトリで、署名済みのリリース用apk上げちゃったら、ダウンロードし放題だよね?

もしかしたら、悪意のある誰かが、そのapkを自分のapkとしてPlayストアにアップロードしてしまうかもしれません。
今は公開しようと思うような出来のアプリではありませんが(汗)、実際にストア公開しようと思っているアプリならば、publicなリポジトリに署名済みapkがあるのはまずい状態ですね。アップロード鍵とする場合には、後から変更する手段はありますが・・・でも同じパッケージ名のアプリはPlayストアに登録できないので、誰かが先に登録してしまうともう登録できないというのもあります。

privateなリポジトリでやるか(従量課金なので気をつける必要あり)、releaseビルドはローカルで行う運用にする、自分でCIサーバーを立ててそこで動かすなどすべきでしょう。
もしくは、/app/build/outputs/apk全体をパスワード付きzipとかにして別の場所に格納し、それをアーティファクトとして保存する、というのも良さそうですね。

ひとまず、リリースビルドについては、CD(継続的デプロイ)の範疇になってくるので、今回のところは、いったんアーティファクトに上げるところをコメントアウトしておきます。既に上がってしまっている物は逐一削除で^^;

まとめ

GithubActionsでビルド、テストの一連を自動化することが出来ました。生成物(apk)やテスト結果レポートをアーカイブ、ダウンロード出来るようになりました。
個人的には、基本的なことは出来るので、これで十分な気がします。
テスト結果をいちいちDLして解凍してからじゃ無いと見られないのが、他のツールと比べて少し面倒でしょうかね。

ここまでのソースは以下のブランチにアップしてあります。
https://github.com/le-kamba/qiita_pedometer/tree/feature/qiita_10

おまけ

"[ci skip] 〜〜"というコメントでコミットすると、CIツールがトリガーされないように出来る機能がCircleCI等にあるようで、GithubActionsでもないかなと調べたのですが、ないようです。
で、こちらの方が紹介している、Jobs.ifという構文を使う方法で入れてみてあります。

予告

CIツールの第2回目をやります。

  • Jenkins
  • CircleCI

についても、一応やってみたので、次回、ご紹介します。

参考ページなど

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AndroidをBLEペリフェラルにしよう

AndroidのAPIを使ってアプリを作成してスマホをBLEペリフェラルにして、PCのブラウザからBLEでつないでみます。
Androidのスマホには、豊富なハードウェアやネットワーク機能がありますので、これがあれば、同じWiFiアクセスポイントにつながなくても、PCのブラウザからBLEでつないでみることができます。

Android

(i) BLEアダプタの取得

        mBleManager = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
        mBleAdapter = mBleManager.getAdapter();

(ii) GATTサーバの作成

        mBtGattServer = mBleManager.openGattServer(this, mGattServerCallback);

mGattServerCallback は、BLEセントラルからの接続が完了したときや、ATT Read/Write が来た時の処理を実装するためのコールバックです。後で説明します。

(iii) GATT構造の作成

PrimaryServiceの下に、3つのキャラクタリスティックを配置します。

・Write用
・Read用
・Notification用

    /* GATT構造(PrimaryService) */
        btGattService = new BluetoothGattService(UUID_LIFF_SERVICE, BluetoothGattService.SERVICE_TYPE_PRIMARY);

    /* GATT構造(Characteristic) */
        mBtCharacteristic1 = new BluetoothGattCharacteristic(UUID_LIFF_WRITE, BluetoothGattCharacteristic.PROPERTY_WRITE, BluetoothGattCharacteristic.PERMISSION_WRITE);
        btGattService.addCharacteristic(mBtCharacteristic1);
        mBtCharacteristic2 = new BluetoothGattCharacteristic(UUID_LIFF_READ, BluetoothGattCharacteristic.PROPERTY_READ, BluetoothGattCharacteristic.PERMISSION_READ);
        btGattService.addCharacteristic(mBtCharacteristic2);
        mNotifyCharacteristic = new BluetoothGattCharacteristic(UUID_LIFF_NOTIFY, BluetoothGattCharacteristic.PROPERTY_NOTIFY, BluetoothGattCharacteristic.PERMISSION_READ);
        btGattService.addCharacteristic(mNotifyCharacteristic);
        BluetoothGattDescriptor dataDescriptor = new BluetoothGattDescriptor(UUID_LIFF_DESC, BluetoothGattDescriptor.PERMISSION_WRITE | BluetoothGattDescriptor.PERMISSION_READ);
        mNotifyCharacteristic.addDescriptor(dataDescriptor);
        mBtGattServer.addService(btGattService);

(iv) アドバタイジングデータの作成

PrimaryServiceのUUIDをサービスUUIDとしてアドバタイジングデータに含めています。そうすることで、BLEセントラル側からBLEペリフェラルをScanする際に検索対象を絞ることができます。
あと、BLEセントラルからわかりやすいように、デバイス名も含めています。

        AdvertiseData.Builder dataBuilder = new AdvertiseData.Builder();
        dataBuilder.setIncludeTxPowerLevel(true);
        dataBuilder.addServiceUuid(ParcelUuid.fromString(UUID_LIFF_SERVICE_STR));

        AdvertiseSettings.Builder settingsBuilder = new AdvertiseSettings.Builder();
        settingsBuilder.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED);
        settingsBuilder.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM);
        settingsBuilder.setTimeout(0);
        settingsBuilder.setConnectable(true);

        AdvertiseData.Builder respBuilder = new AdvertiseData.Builder();
        respBuilder.setIncludeDeviceName(true);

(v) アドバタイズの開始

        mBtAdvertiser.startAdvertising(settingsBuilder.build(), dataBuilder.build(), respBuilder.build(), new AdvertiseCallback(){
            @Override
            public void onStartSuccess(AdvertiseSettings settingsInEffect) {
                Log.d("bleperi", "onStartSuccess");
            }
            @Override
            public void onStartFailure(int errorCode) {
                Log.d("bleperi", "onStartFailure");
                handler.sendTextMessage("BLEを開始できませんでした。");
            }
        });

(vi) コールバック関数

BLEの状態が変わったり、BLEセントラルからのリクエストを受け取った時に呼び出される関数を実装しています。
ここが一番重要なところです。

    private BluetoothGattServerCallback mGattServerCallback = new BluetoothGattServerCallback() {

主なものとして以下があります。

・public void onMtuChanged (BluetoothDevice device, int mtu){

 クライアントからの要求により、MTUサイズが変更されたときに呼び出されます。MTUサイズが必要な場合はここで取得した値を覚えておくようにします。
 ブラウザのWeb Bluetooth APIからは、512バイトに拡大されました。NordicのnRF Connect for Mobileは拡大されず20バイトずつの送受信となりました。クライアント(BLEセントラル)によって違うようです。

・public void onConnectionStateChange(android.bluetooth.BluetoothDevice device, int status, int newState) {

 BLEセントラルと接続されたり切断されたときに呼び出されます。ですが、最新のAndroidOSバージョンでは、誰も接続していないはずなのに、接続状態となります。謎です。

・public void onCharacteristicReadRequest(android.bluetooth.BluetoothDevice device, int requestId, int offset, BluetoothGattCharacteristic characteristic) {

 BLEセントラルから、ATT Readが呼び出されたときに、呼び出されます。要は、読み出し要求です。
 内部のバッファに、受信しておいたデータを返してあげましょう。
 ポイントは以下の部分です。

        if( offset > charValue.length ) {
            mBtGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null);
        }else {
            byte[] value = new byte[charValue.length - offset];
            System.arraycopy(charValue, offset, value, 0, value.length);
            mBtGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value);
        }

BLEセントラルとBLEペリフェラルの間での1回のBLEパケットの長さは、MTUサイズです。この長さを超えて送受信するには、複数のパケットを投げる必要があります。
offsetは、どこを起点として読み出すかをBLEセントラル側が指定します。BLEセントラル側は、BLEペリフェラルから受信したデータ長がMTUサイズ以下か、エラーが返ってくるまで、offsetを進めながらATT Readを繰り返し呼び出します。それに耐えられるような実装にしたつもりです。

・public void onCharacteristicWriteRequest(android.bluetooth.BluetoothDevice device, int requestId, BluetoothGattCharacteristic characteristic, boolean preparedWrite, boolean responseNeeded, int offset, byte[] value) {

 BLEセントラルから、ATT Writeが呼び出されたときに、呼び出されます。要は、書き込み要求です。
 内部のバッファに、受信データを保存しましょう。BLEセントラル側からoffsetを使って分割してATT Writeされる場合があるので、offsetが内部保持可能なバッファサイズを超えたらエラーを返しています。

        if(offset < charValue.length ) {
            int len = value.length;
            if( (offset + len ) > charValue.length)
                len = charValue.length - offset;
            System.arraycopy(value, 0, charValue, offset, len);
            mBtGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null);
        }else {
            mBtGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null);
        }

・public void onDescriptorWriteRequest(BluetoothDevice device, int requestId, BluetoothGattDescriptor descriptor, boolean preparedWrite, boolean responseNeeded, int offset, byte[] value) {

 主に、CCCD(Client Characteristic Configuration Descriptor)の操作に使います。
 CCCDは2バイトです。受信データの1バイト目が1だったら、Notificationが有効で、0だったら無効ですので、Notification送信時に確認するようにします。

・public void onDescriptorReadRequest(BluetoothDevice device, int requestId, int offset, BluetoothGattDescriptor descriptor) {

 主に、CCCD(Client Characteristic Configuration Descriptor)の値の読み出しに使います。

(vii) その他

  • 各種UUIDを使っていますが、適当に払い出したものなので、UUIDは各自払い出してください。
  • Manifestには以下の許可が必要だと思います。
    <uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>

    <uses-permission android:name="android.permission.BLUETOOTH" />
    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />

ちなみに、画面はこんな感じ。

image.png

クライアント側(ブラウザ)からつなぐ

動作確認用に、Webページを作ってみました。
HTML5に、Web Bluetooth APIがあり、ChromeからBLEペリフェラルと通信することができるので、それを使いました。
Windows10で確認しましたが、iMacやAndroidでも動くはずです。

image.png

まずは、「接続」ボタンを押下します。

image.png

そうすると、設定したPrimaryServiceのUUIDを持ったサービスがリストアップされます。
いづれかを選択して、「ペア設定」を押下すると、BLE接続処理が走ります。Notificationも有効にしています。
接続が完了すると、connected のところが、 true になっているかと思います。

あとは、write valueのテキストエリアところに、16進数文字列を指定して、「Write」ボタンを押下すると、ATT Writeが実行され、「Read」ボタンを押下すると、ATT Readが実行されます。
ちなみに、write valueの先頭1バイトが0xFFの場合には、Notificationが発行されるようにAndroid側を実装しています。
ATT Readで受信したデータはread valueのテキストエリアに、Notificationで受信したデータはnotify valueのテキストエリアに表示するようにしています。

HTMLファイルです。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval'; style-src * 'unsafe-inline'; media-src *; img-src * data: content:;">
    <meta name="format-detection" content="telephone=no">
    <meta name="msapplication-tap-highlight" content="no">
    <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">

  <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
  <script src="https://code.jquery.com/jquery-1.12.4.min.js" integrity="sha384-nvAa0+6Qg9clwYCGGPpDQLVpLNn0fRaROjHqs13t4Ggj3Ez50XnGQqc/r8MhnRDZ" crossorigin="anonymous"></script>
  <!-- Latest compiled and minified CSS -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
  <!-- Optional theme -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap-theme.min.css" integrity="sha384-6pzBo3FDv/PJ8r2KRkGHifhEocL+1X2rVCTTkUfGk7/0pbek5mMa1upzvWbrUbOZ" crossorigin="anonymous">
  <!-- Latest compiled and minified JavaScript -->
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js" integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd" crossorigin="anonymous"></script>

  <title>BLE Peripheral Test</title>

  <script src="js/methods_utils.js"></script>
  <script src="js/vue_utils.js"></script>

  <script src="dist/js/vconsole.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
</head>
<body>
    <div id="top" class="container">
        <h1>BLE Peripheral Test</h1>
        <label>connected</label> {{ble_connected}}<br>
        <label>deviceName</label> {{ble_devicename}}<br>
        <button class="btn btn-default" v-on:click="ble_connect">接続</button>
        <br><br>
        <label>write value</label>
        <textarea type="string" rows="5" class="form-control" v-model="ble_write_value"></textarea>
        <button class="btn btn-default" v-on:click="ble_write">Write</button>
        <br><br>
        <button class="btn btn-default" v-on:click="ble_read">Read</button>
        <br>
        <label>read value</label>
        <textarea type="string" rows="5" class="form-control" v-model="ble_read_value" readonly></textarea>
        <br>
        <label>notify value</label>
        <textarea type="string" rows="5" class="form-control" v-model="ble_notify_value" readonly></textarea>



        <div class="modal fade" id="progress">
            <div class="modal-dialog">
                <div class="modal-content">
                    <div class="modal-header">
                        <h4 class="modal-title">{{progress_title}}</h4>
                    </div>
                    <div class="modal-body">
                        <center><progress max="100" /></center>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <script src="js/start.js"></script>
</body>
</html>

Javascriptです。
UUIDは、Androidで払い出したUUIDに合わせてください。

start.js
'use strict';

//var vConsole = new VConsole();

const UUID_ANDROID_SERVICE = 'a9d158bb-9007-4fe3-b5d2-d3696a3eb067';
const UUID_ANDROID_WRITE = '52dc2801-7e98-4fc2-908a-66161b5959b0';
const UUID_ANDROID_READ = '52dc2802-7e98-4fc2-908a-66161b5959b0';
const UUID_ANDROID_NOTIFY = '52dc2803-7e98-4fc2-908a-66161b5959b0';

const ANDROID_WAIT = 200;

var bluetoothDevice = null;
var characteristics = new Map();

var vue_options = {
    el: "#top",
    data: {
        progress_title: '',

        ble_connected: false,
        ble_devicename: '', 
        ble_write_value: '',
        ble_read_value: '',
        ble_notify_value: '',
    },
    computed: {
    },
    methods: {
        ble_connect: function(){
            return this.requestDevice(UUID_ANDROID_SERVICE)
            .then( (name) => {
                this.progress_open();
                this.ble_devicename = name;
                return bluetoothDevice.gatt.connect()
                .then(server => {
                    console.log('Execute : getPrimaryService');
                    return wait_async(ANDROID_WAIT)
                    .then(() =>{
                        return server.getPrimaryService(UUID_ANDROID_SERVICE);
                    });
                })
                .then(service => {
                    console.log('Execute : getCharacteristic');
                    characteristics.clear();
                    return Promise.all([
                        this.setCharacteristic(service, UUID_ANDROID_WRITE),
                        this.setCharacteristic(service, UUID_ANDROID_READ),
                        this.setCharacteristic(service, UUID_ANDROID_NOTIFY)
                    ]);
                })
                .then(values => {
                    return wait_async(ANDROID_WAIT)
                    .then(() =>{
                        return this.startNotify(UUID_ANDROID_NOTIFY);
                    });
                })
                .then(() =>{
                    this.ble_connected = true;
                    console.log('ble_connect done');
                    return bluetoothDevice.name;
                })
                .catch(error =>{
                    alert(error);
                })
                .finally(() => {
                    this.progress_close();
                });
            })
            .catch(error =>{
                alert(error);
            });
        },
        ble_read: function(){
            return this.readChar(UUID_ANDROID_READ);
        },
        ble_write: function(){
            return this.writeChar(UUID_ANDROID_WRITE, hexs2bytes(this.ble_write_value, ''));
        },
        requestDevice: function(service_uuid){
            console.log('Execute : requestDevice');

            return navigator.bluetooth.requestDevice({
                filters: [{services:[ service_uuid ]}]
        //      acceptAllDevices: true,
        //      optionalServices: [service_uuid]
                }
            )
            .then(device => {
                console.log("requestDevice OK");
                characteristics.clear();
                bluetoothDevice = device;
                bluetoothDevice.addEventListener('gattserverdisconnected', this.onDisconnect);
                return bluetoothDevice.name;
            });
        },
        setCharacteristic: function(service, characteristicUuid) {
            console.log('Execute : setCharacteristic : ' + characteristicUuid);

            return wait_async(ANDROID_WAIT)
            .then(() => {
                return service.getCharacteristic(characteristicUuid);
            })
            .then( (characteristic) =>{
                characteristics.set(characteristicUuid, characteristic);
                characteristic.addEventListener('characteristicvaluechanged', this.onDataChanged);
                return service;
            });
        },
        onDisconnect: function(event){
            console.log('onDisconnect');
            characteristics.clear();
            this.ble_connected = false;
        },
        onDataChanged: function(event){
            console.log('onDataChanged');

            let characteristic = event.target;
            let packet = uint8array_to_array(characteristic.value);
            if( characteristic.uuid == UUID_ANDROID_READ ){
                this.ble_read_value = bytes2hexs(packet, '');
            }else if( characteristic.uuid == UUID_ANDROID_NOTIFY ){
                this.ble_notify_value = bytes2hexs(packet, '');
            }
        },    
        startNotify: function(uuid) {
            if( characteristics.get(uuid) === undefined )
                throw "Not Connected";

            console.log('Execute : startNotifications');
            return characteristics.get(uuid).startNotifications();
        },
        stopNotify: function(uuid){
            if( characteristics.get(uuid) === undefined )
                throw "Not Connected";

            console.log('Execute : stopNotifications');
            return characteristics.get(uuid).stopNotifications();
        },
        writeChar: function(uuid, array_value) {
            if( characteristics.get(uuid) === undefined )
                throw "Not Connected";

            console.log('Execute : writeValue');
            let data = Uint8Array.from(array_value);
            return characteristics.get(uuid).writeValue(data);
        },
        readChar: function(uuid){
            if( characteristics.get(uuid) === undefined )
                throw "Not Connected";

            console.log('Execute : readValue');
            return characteristics.get(uuid).readValue((dataView) =>{
                console.log(dataView);
            });
        }
    },
    created: function(){
        proc_load();

        var ary = [];
        for( var i = 0 ; i < 500 ; i++ )
            ary[i] = i & 0xff;
        this.ble_write_value = bytes2hexs(ary, '');
    }
};
vue_add_methods(vue_options, methods_utils);
var vue = new Vue( vue_options );

function hexs2bytes(hexs, sep = ' ') {
    hexs = hexs.trim(hexs);
    if( sep == '' )
    {
        hexs = hexs.replace(/ /g, "");
        var array = [];
        for( var i = 0 ; i < hexs.length / 2 ; i++)
            array[i] = parseInt(hexs.substr(i * 2, 2), 16);
        return array;
    }else{
        return hexs.split(sep).map(function(h) { return parseInt(h, 16) });
    }
}

function bytes2hexs(bytes, sep = ' ') {
    return bytes.map(function(b) { var s = b.toString(16); return b < 0x10 ? '0'+s : s; }).join(sep).toUpperCase();
}

function uint8array_to_array(array)
{
    var result = new Array(array.byteLength);
    var i;
    for( i = 0 ; i < array.byteLength ; i++ )
        result[i] = array.getUint8(i);

    return result;
}

function wait_async(timeout){
    return new Promise((resolve, reject) =>{
        setTimeout(resolve, timeout);
    });
}

足りないソースファイルは以下にあります。

 https://github.com/poruruba/swagger_template/tree/master/public

主要な関数について解説します。

〇ble_connect
 BLEデバイスに接続し、PrimaryServiceやCharacteristicを検索します。また、Notificationも有効化します。
 HTML5のWeb Bluetooth APIを使っているため、本Webページは、HTTPSでホスティングされている必要があります。
 (随所にウェイトをいれていますが、なくても大丈夫かもしれません。)

〇ble_write
 ATT Writeの関数です。
 MTUサイズに合わせて、勝手に分割送信してくれているようです。ですが、最大でも512バイトだそうです。

〇ble_read
 ATT Readの関数です。ただし、読み出したデータは、 onDataChanged がコールバックされて取得できます。

〇onDataChanged
 ATT Readしたときに読みだしが完了したときと、Notificationを受信したときに呼び出されます。

参考情報

・Android Deveopers Reference
https://developer.android.com/reference/android/bluetooth/package-summary
https://developer.android.com/reference/android/bluetooth/le/package-summary

・Web Bluetoot
https://tkybpp.github.io/web-bluetooth-jp/

(参考) Android側のソースコードです。長いです。

MainActivity.java
package com.example.test.bleperi.bleperipheraltest;

import android.app.Activity;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothGattServer;
import android.bluetooth.BluetoothGattServerCallback;
import android.bluetooth.BluetoothGattService;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothProfile;
import android.bluetooth.le.AdvertiseCallback;
import android.bluetooth.le.AdvertiseData;
import android.bluetooth.le.AdvertiseSettings;
import android.bluetooth.le.BluetoothLeAdvertiser;
import android.content.Context;
import android.os.Message;
import android.os.ParcelUuid;
import android.os.Bundle;
import android.util.Log;
import android.widget.TextView;
import android.widget.Toast;
import java.util.UUID;
import static com.example.test.bleperi.bleperipheraltest.UIHandler.MSG_ID_OBJ_BASE;
import static com.example.test.bleperi.bleperipheraltest.UIHandler.MSG_ID_TEXT;

public class MainActivity extends Activity implements UIHandler.Callback {
    UIHandler handler;
    BluetoothManager mBleManager;
    BluetoothAdapter mBleAdapter;
    BluetoothLeAdvertiser mBtAdvertiser;
    BluetoothGattCharacteristic mPsdiCharacteristic;
    BluetoothGattCharacteristic mBtCharacteristic1;
    BluetoothGattCharacteristic mBtCharacteristic2;
    BluetoothGattCharacteristic mNotifyCharacteristic;
    BluetoothGattService btPsdiService;
    BluetoothGattService btGattService;
    BluetoothGattServer mBtGattServer;
    boolean mIsConnected = false;
    BluetoothDevice mConnectedDevice;

//    private static final short WIRELESS_BLE_MAX_L2CAP_SIZE = 23;

    private static final UUID UUID_LIFF_PSDI_SERVICE = UUID.fromString("e625601e-9e55-4597-a598-76018a0d293d");
    private static final UUID UUID_LIFF_PSDI = UUID.fromString("26e2b12b-85f0-4f3f-9fdd-91d114270e6e");
    private static final String UUID_LIFF_SERVICE_STR = "a9d158bb-9007-4fe3-b5d2-d3696a3eb067";

    private static final UUID UUID_LIFF_SERVICE = UUID.fromString(UUID_LIFF_SERVICE_STR);
    private static final UUID UUID_LIFF_WRITE = UUID.fromString("52dc2801-7e98-4fc2-908a-66161b5959b0");
    private static final UUID UUID_LIFF_READ = UUID.fromString("52dc2802-7e98-4fc2-908a-66161b5959b0");
    private static final UUID UUID_LIFF_NOTIFY = UUID.fromString("52dc2803-7e98-4fc2-908a-66161b5959b0");
    private static final UUID UUID_LIFF_DESC = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");

    private static final int UUID_LIFF_VALUE_SIZE = 500;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        handler = new UIHandler(this);

        mBleManager = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
        mBleAdapter = mBleManager.getAdapter();

        if(mBleAdapter != null){
            prepareBle();
        }
    }

    @Override
    public boolean handleMessage(Message message) {
        switch (message.what) {
            case MSG_ID_TEXT: {
                TextView txt;
                txt = (TextView) findViewById(R.id.txt_message);
                txt.setText((String) message.obj);
                return true;
            }
            case MSG_ID_OBJ_BASE: {
                if( message.arg1 == 1 ){
                    TextView txt;
                    txt = (TextView) findViewById(message.arg2);
                    txt.setText((String)message.obj);
                }
                break;
            }
        }
        return false;
    }

    private void prepareBle(){
        mBtAdvertiser = mBleAdapter.getBluetoothLeAdvertiser();
        if( mBtAdvertiser == null ){
            Toast.makeText(this, "BLE Peripheralモードが使用できません。", Toast.LENGTH_SHORT).show();
            return;
        }

        mBtGattServer = mBleManager.openGattServer(this, mGattServerCallback);

        btPsdiService = new BluetoothGattService(UUID_LIFF_PSDI_SERVICE, BluetoothGattService.SERVICE_TYPE_PRIMARY);
        mPsdiCharacteristic = new BluetoothGattCharacteristic(UUID_LIFF_PSDI, BluetoothGattCharacteristic.PROPERTY_READ, BluetoothGattCharacteristic.PERMISSION_READ);
        btPsdiService.addCharacteristic(mPsdiCharacteristic);
        mBtGattServer.addService(btPsdiService);

        try { Thread.sleep(200); }catch(Exception ex){}

        btGattService = new BluetoothGattService(UUID_LIFF_SERVICE, BluetoothGattService.SERVICE_TYPE_PRIMARY);

        mBtCharacteristic1 = new BluetoothGattCharacteristic(UUID_LIFF_WRITE, BluetoothGattCharacteristic.PROPERTY_WRITE, BluetoothGattCharacteristic.PERMISSION_WRITE);
        btGattService.addCharacteristic(mBtCharacteristic1);
        mBtCharacteristic2 = new BluetoothGattCharacteristic(UUID_LIFF_READ, BluetoothGattCharacteristic.PROPERTY_READ, BluetoothGattCharacteristic.PERMISSION_READ);
        btGattService.addCharacteristic(mBtCharacteristic2);
        mNotifyCharacteristic = new BluetoothGattCharacteristic(UUID_LIFF_NOTIFY, BluetoothGattCharacteristic.PROPERTY_NOTIFY, BluetoothGattCharacteristic.PERMISSION_READ);
        btGattService.addCharacteristic(mNotifyCharacteristic);
        BluetoothGattDescriptor dataDescriptor = new BluetoothGattDescriptor(UUID_LIFF_DESC, BluetoothGattDescriptor.PERMISSION_WRITE | BluetoothGattDescriptor.PERMISSION_READ);
        mNotifyCharacteristic.addDescriptor(dataDescriptor);
        mBtGattServer.addService(btGattService);

        try { Thread.sleep(200); }catch(Exception ex){}

        startBleAdvertising();
    }

    private void startBleAdvertising(){
        AdvertiseData.Builder dataBuilder = new AdvertiseData.Builder();
        dataBuilder.setIncludeTxPowerLevel(true);
        dataBuilder.addServiceUuid(ParcelUuid.fromString(UUID_LIFF_SERVICE_STR));

        AdvertiseSettings.Builder settingsBuilder = new AdvertiseSettings.Builder();
        settingsBuilder.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED);
        settingsBuilder.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM);
        settingsBuilder.setTimeout(0);
        settingsBuilder.setConnectable(true);

        AdvertiseData.Builder respBuilder = new AdvertiseData.Builder();
        respBuilder.setIncludeDeviceName(true);

        mBtAdvertiser.startAdvertising(settingsBuilder.build(), dataBuilder.build(), respBuilder.build(), new AdvertiseCallback(){
            @Override
            public void onStartSuccess(AdvertiseSettings settingsInEffect) {
                Log.d("bleperi", "onStartSuccess");
            }
            @Override
            public void onStartFailure(int errorCode) {
                Log.d("bleperi", "onStartFailure");
                handler.sendTextMessage("BLEを開始できませんでした。");
            }
        });
    }

    private BluetoothGattServerCallback mGattServerCallback = new BluetoothGattServerCallback() {
        private byte[] psdiValue = new byte[8];
        private byte[] notifyDescValue = new byte[2];
        private byte[] charValue = new byte[UUID_LIFF_VALUE_SIZE]; /* max 512 */

        @Override
        public void onMtuChanged (BluetoothDevice device, int mtu){
            Log.d("bleperi", "onMtuChanged(" + mtu + ")");
        }

        @Override
        public void onConnectionStateChange(android.bluetooth.BluetoothDevice device, int status, int newState) {
            Log.d("bleperi", "onConnectionStateChange");

            if(newState == BluetoothProfile.STATE_CONNECTED){
                mConnectedDevice = device;
                mIsConnected = true;
                Log.d("bleperi", "STATE_CONNECTED:" + device.toString());
                handler.sendTextMessage("接続されました。");
                handler.sendUIMessage(MSG_ID_OBJ_BASE, 1, R.id.txt_target_address, device.getAddress());
            }
            else{
                mIsConnected = false;
                Log.d("bleperi", "Unknown STATE:" + newState);
                handler.sendTextMessage("切断されました。");
                handler.sendUIMessage(MSG_ID_OBJ_BASE, 1, R.id.txt_target_address, "");
            }
        }

        public void onCharacteristicReadRequest(android.bluetooth.BluetoothDevice device, int requestId, int offset, BluetoothGattCharacteristic characteristic) {
            Log.d("bleperi", "onCharacteristicReadRequest");

            if( characteristic.getUuid().compareTo(UUID_LIFF_PSDI) == 0) {
                mBtGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, psdiValue);
            }else if( characteristic.getUuid().compareTo(UUID_LIFF_READ) == 0){
                handler.sendUIMessage(MSG_ID_OBJ_BASE, 1, R.id.txt_access, "Read");
                handler.sendUIMessage(MSG_ID_OBJ_BASE, 1, R.id.txt_offset, Integer.toString(offset));
                handler.sendUIMessage(MSG_ID_OBJ_BASE, 1, R.id.txt_length, "");
                handler.sendUIMessage(MSG_ID_OBJ_BASE, 1, R.id.txt_value, "");

                if( offset > charValue.length ) {
                    mBtGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null);
                }else {
                    byte[] value = new byte[charValue.length - offset];
                    System.arraycopy(charValue, offset, value, 0, value.length);
                    mBtGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value);
                }
            }else{
                mBtGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null );
            }
        }

        public void onCharacteristicWriteRequest(android.bluetooth.BluetoothDevice device, int requestId, BluetoothGattCharacteristic characteristic, boolean preparedWrite, boolean responseNeeded, int offset, byte[] value) {
            Log.d("bleperi", "onCharacteristicWriteRequest");

            if( characteristic.getUuid().compareTo(UUID_LIFF_WRITE) == 0 ){
                handler.sendUIMessage(MSG_ID_OBJ_BASE, 1, R.id.txt_access, "Write");
                handler.sendUIMessage(MSG_ID_OBJ_BASE, 1, R.id.txt_offset, Integer.toString(offset));
                handler.sendUIMessage(MSG_ID_OBJ_BASE, 1, R.id.txt_length, Integer.toString(value.length));
                handler.sendUIMessage(MSG_ID_OBJ_BASE, 1, R.id.txt_value, MainActivity.toHexString(value));

                if(offset < charValue.length ) {
                    int len = value.length;
                    if( (offset + len ) > charValue.length)
                        len = charValue.length - offset;
                    System.arraycopy(value, 0, charValue, offset, len);
                    mBtGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null);
                }else {
                    mBtGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null);
                }

                if( (notifyDescValue[0] & 0x01) != 0x00 ) {
                    if (offset == 0 && value[0] == (byte) 0xff) {
                        mNotifyCharacteristic.setValue(charValue);
                        mBtGattServer.notifyCharacteristicChanged(mConnectedDevice, mNotifyCharacteristic, false);
                        handler.sendTextMessage("Notificationしました。");
                    }
                }
            }else{
                mBtGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_FAILURE, offset, null);
            }
        }

        public void onDescriptorReadRequest(BluetoothDevice device, int requestId, int offset, BluetoothGattDescriptor descriptor) {
            Log.d("bleperi", "onDescriptorReadRequest");

            if( descriptor.getUuid().compareTo(UUID_LIFF_DESC) == 0 ) {
                mBtGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, notifyDescValue);
            }
        }

        public void onDescriptorWriteRequest(BluetoothDevice device, int requestId, BluetoothGattDescriptor descriptor, boolean preparedWrite, boolean responseNeeded, int offset, byte[] value) {
            Log.d("bleperi", "onDescriptorWriteRequest");

            if( descriptor.getUuid().compareTo(UUID_LIFF_DESC) == 0 ) {
                notifyDescValue[0] = value[0];
                notifyDescValue[1] = value[1];

                mBtGattServer.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null);
            }
        }
    };

    public static String toHexString(byte[] data) {
        StringBuffer sb = new StringBuffer();
        for (byte b : data) {
            String s = Integer.toHexString(0xff & b);
            if (s.length() == 1)
                sb.append("0");
            sb.append(s);
        }
        return sb.toString();
    }
}

あとがき

投稿したはいいけど、読者には結構BLEの知識が必要かも。。。
実験したいことがあればお知らせください。

以上

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity】【 Androidビルド向け環境構築】

さて、本日はUnityでARを試してみたい。
やりたいことは、Androidデバイスのカメラ機能に、指定の経度緯度にでっかい3Dモデルを出現させるというものだ。
既に先駆者がいるがそれはIPhonseだった。
【ARKit】GPSで特定の場所にARを表示する

ちなみに似た様なことをAndroidでやっている記事はあったので、この2つの記事を参考にしつつ、そのやりたいことを実現しようと思う。
UnityでARアプリの作り方1/4 環境構築+androidビルドからカメラ表示

そもそも僕はUnityでAndroid向けの開発はやったことないから、
まずは上記記事を参考にして環境構築から始める。

環境

Windows10
メモリ8G
SSD
Unity 2019.3.3f1

レッツゴー

まずはAndroid Studioを公式サイトからダウンロードして
ひたすらデフォルト的なインストールする。
https://developer.android.com/studio/install?hl=ja

次に、Unity Hub でAndroidビルド用のモジュールを追加する
image.png

そして、新たなUnity Projectを新規作成して(ターゲットはAndroidにして)
image.png

それを開く。
Edit > Preference > External Tools からSDKの位置を確認すると何やら警告が出ている。
image.png

警告メッセージ↓↓
You are missing the recommended JDK. Install the recommended verion using Unity Hub
You are missing the recommended SDK. Install the recommended verion using Unity Hub
You are missing the recommended NDK. Install the recommended verion using Unity Hub

あれ、、さっきのモジュール追加で入れたんちゃうんかと思って、このメッセージでぐぐると、
https://yanpen.net/unity/install_android-sdk-ndk_from_unity_hub/ こちらの記事がでてきた。
モジュール追加のときにトグル開いて、そのなかでSDKとかインストールする項目があったのね。
それ見落としててチェックし損なってた。

というわけで、
これを
image.png

こーして、再度インストール
image.png

少しインストールに時間かけてる間にポットでお湯を沸かして生姜湯を作成し一息つく(ふ~。)

インストールが完了したら先ほど作ったUnity Projectを再度開き、また
Edit > Preference > External Tools を開く。
諸々の警告は下記の様に消えている。
image.png

ほな、ビルドしてみましょう。
image.png

Build And Run を選択して、出力先フォルダとファイル名を指定する。
そして少し待たされる。

そして、無事PCにUSB接続したAndroid端末で作成されたapkファイルが実行された。
image.png

ひとまずOKというところ。
とくにつまづかずめだたしめでたし。

次号では、もう少し形にしたARアプリの記事を書く。
ではでは。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

いろいろなCoordinatorLayoutパターン

最初に一言

スクロールに合わせて消えるあのかっこいいやつやりたい。-> なるほど。CoordinatorLayoutってヤツを使えばできると。-> えっナニコレ、CoordinatorLayoutすごく使いづらいYO、...ってことで、今後のためにCoordinatorLayoutの組み合わせをメモっておこうと思った。

CoordinatorLayoutって?

個人的にはめちゃくちゃ使いづらいレイアウトってイメージ。分類的にはFrameLayoutだとか。
簡単な一からの実装方法はこちらを参考に ドラッグで始める最速CoordinatorLayout

これを使えばどんなことができるのか?
↓こういうよく見るスクロールに合わせて消えるヤツ

今回はCoordinatorlayoutを使ったサンプルをレイアウトファイルに焦点を当て、いくつか紹介してみる。
RecyclerViewやViewPagerの細かい実装は今回は省略。
なおコードはgithubに挙げているのでレイアウトファイル以外を見たい方は適宜参照してほしい。
(※今回のコードはすべてkotlin, android x に対応したものになっています。)

Coordinatorlayout + RecyclerView

一番シンプルなやつ

なお、前提としてアクションバーは非表示のテーマになっているとする。(非表示のやり方は一番下に)

キャcafプチャ.PNG キャプチvaertャ.PNG

メインのレイアウトファイル

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appbar"
        android:layout_width="match_parent"
        android:layout_height="230dp">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:minHeight="?attr/actionBarSize"
            app:contentScrim="?attr/colorPrimary"
            app:layout_scrollFlags="scroll|exitUntilCollapsed"
            app:toolbarId="@+id/toolbar">

            <ImageView
                android:id="@+id/imageView2"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:scaleType="centerCrop"
                app:layout_collapseMode="parallax"
                app:srcCompat="@drawable/house" />

            <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin"
                app:title="スイスの湖小屋" />
        </com.google.android.material.appbar.CollapsingToolbarLayout>
    </com.google.android.material.appbar.AppBarLayout>

    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

ここで注意が必要なのがCollapsingToolbarLayoutの属性にminHeightを指定しておかないとRecyclerViewの一番下の要素がtoolbarの分だけ表示しきれずに切れてしまう。

また、アクションバー自体も消えるようにしたいときはCollapsingToolbarLayoutの属性をapp:layout_scrollFlags="scroll"に変更するとアクションバーも隠れるようになる。

こちらのコードはgithubからどうぞ

CoordinatorLayout + RecyclerView + ViewPager

ViewPagerでフラグメントを切り替え、そのフラグメントの中にRecyclerViewが入ってるパターン

なお、前提としてアクションバーは非表示のテーマになっているとする。(非表示のやり方は一番下に)

キャプチャcadsfjty.PNG キccgagdャプチャ.PNG

ViewPagerをNestedScrollViewの中に入れていると、ViewPagerとその中のフラグメントが表示されないので注意。viewPagerを中に入れてると大きさをmatch_parentにしようが、wrap_contentにしようが何故かviewPagerの大きさが0になる。固定値でheight:500dpとか指定したらいけるけど、それじゃぁ、あんまり使えないよね...

メインのレイアウトファイル

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appbar"
        android:layout_width="match_parent"
        android:layout_height="230dp">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:contentScrim="?attr/colorPrimary"
            app:layout_scrollFlags="scroll|exitUntilCollapsed"
            app:toolbarId="@+id/toolbar">

            <ImageView
                android:id="@+id/imageView"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:scaleType="centerCrop"
                app:srcCompat="@drawable/house" />

            <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:title="スイスの湖小屋"></androidx.appcompat.widget.Toolbar>
        </com.google.android.material.appbar.CollapsingToolbarLayout>
    </com.google.android.material.appbar.AppBarLayout>

    <androidx.viewpager.widget.ViewPager
        android:id="@+id/viewPager"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

フラグメントのレイアウトファイル

ViewPagerで表示を切り替えるフラグメント(この中にRecyclerViewが入ってる)
キャプccfaチャ.PNG   キcvadgャプチャ.PNG

fragment_view_pager.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ViewPagerFragment" >

    <TextView
        android:id="@+id/positionText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="4dp"
        android:text="TextView"
        android:textSize="24sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginTop="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/positionText" />
</androidx.constraintlayout.widget.ConstraintLayout>

親のViewPagerでapp:layout_behavior属性を定義しているのでRecyclerViewにapp:layout_behavior属性を追加しなくても動く。

こちらのコードはgithubからどうぞ

CoordinatorLayout + RecyclerView + ViewPager + TabLayout

toolbarとtabLayoutも消える

なお、前提としてアクションバーは非表示のテーマになっているとする。(非表示のやり方は一番下に)
キdafャプチャ.PNG  キャccvasdfプチャ.PNG

メインのレイアウトファイル

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            app:layout_scrollFlags="scroll|enterAlwaysCollapsed"></androidx.appcompat.widget.Toolbar>

        <com.google.android.material.tabs.TabLayout
            android:id="@+id/tabLayout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_collapseMode="parallax"
            app:layout_scrollFlags="scroll|enterAlwaysCollapsed"
            app:tabIndicatorFullWidth="false"
            app:tabMode="scrollable">

            <com.google.android.material.tabs.TabItem
                android:id="@+id/tabItem1"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="TAB01" />

            <com.google.android.material.tabs.TabItem
                android:id="@+id/tabItem2"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="TAB02" />

            <com.google.android.material.tabs.TabItem
                android:id="@+id/tabItem3"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="TAB03" />

            <com.google.android.material.tabs.TabItem
                android:id="@+id/tabItem4"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="TAB04" />
        </com.google.android.material.tabs.TabLayout>

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.viewpager.widget.ViewPager
        android:id="@+id/viewPager"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

フラグメントのレイアウトファイル

ViewPagerの中身のフラグメント。この中にrecyclerViewが入ってる
上記のCoordinatorLayout + RecyclerView + ViewPagerと同じため省略

こちらのコードはgithubからどうぞ

toolbarだけ消える

上記のtoolbarとtablayoutも消えるとほぼ同じ。
tablayoutlayout_scrollFlags属性を削除すればできる。

activity_main.xml
        <com.google.android.material.tabs.TabLayout
            android:id="@+id/tabLayout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_collapseMode="parallax"
            app:tabIndicatorFullWidth="false"
            app:tabMode="scrollable">

tabLayoutだけ消える


なお、前提としてアクションバーは非表示のテーマになっているとする。(非表示のやり方は一番下に)
これも上記のtoolbarとtablayoutも消えるとほぼ同じ
メインのレイアウトファイルを以下のように変更する。フラグメントは変わらず。
キャプチcafddsaャ.PNG  キャプチgacxvzャ.PNG
ちょっと無理やりだがこうすればいける

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.appcompat.widget.Toolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:background="@color/colorPrimary"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_scrollFlags="scroll|enterAlwaysCollapsed"></androidx.appcompat.widget.Toolbar>

    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:id="@+id/coordinatorLayout"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/toolbar">

        <com.google.android.material.appbar.AppBarLayout
            android:id="@+id/appbar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <com.google.android.material.tabs.TabLayout
                android:id="@+id/tabLayout"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                app:layout_collapseMode="parallax"
                app:layout_scrollFlags="scroll|enterAlwaysCollapsed"
                app:tabIndicatorFullWidth="false"
                app:tabMode="scrollable">

                <com.google.android.material.tabs.TabItem
                    android:id="@+id/tabItem1"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="TAB01" />

                <com.google.android.material.tabs.TabItem
                    android:id="@+id/tabItem2"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="TAB02" />

                <com.google.android.material.tabs.TabItem
                    android:id="@+id/tabItem3"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="TAB03" />

                <com.google.android.material.tabs.TabItem
                    android:id="@+id/tabItem4"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="TAB04" />
            </com.google.android.material.tabs.TabLayout>

        </com.google.android.material.appbar.AppBarLayout>

        <androidx.viewpager.widget.ViewPager
            android:id="@+id/viewPager"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />

    </androidx.coordinatorlayout.widget.CoordinatorLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

こちらのコードはgithubからどうぞ

画像入り


なお、前提としてアクションバーは非表示のテーマになっているとする。(非表示のやり方は一番下に)

キャプチcafddsaャ.PNG  キャプチgacxvzャ.PNG

メインのレイアウトファイル

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"

    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:contentScrim="?attr/colorPrimary"
            app:layout_scrollFlags="scroll|exitUntilCollapsed"
            app:toolbarId="@+id/toolbar">

            <ImageView
                android:id="@+id/imageView2"
                android:layout_width="match_parent"
                android:layout_height="200dp"
                android:scaleType="centerCrop"
                app:layout_collapseMode="parallax"
                app:srcCompat="@drawable/house" />

            <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_scrollFlags="scroll|enterAlways|enterAlwaysCollapsed"
                app:title="スイスの湖小屋"></androidx.appcompat.widget.Toolbar>

        </com.google.android.material.appbar.CollapsingToolbarLayout>

        <com.google.android.material.tabs.TabLayout
            android:id="@+id/tabLayout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_scrollFlags="enterAlwaysCollapsed"
            app:tabIndicatorFullWidth="false"
            app:tabMode="scrollable">

            <com.google.android.material.tabs.TabItem
                android:id="@+id/tabItem1"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="TAB01" />

            <com.google.android.material.tabs.TabItem
                android:id="@+id/tabItem2"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="TAB02" />

            <com.google.android.material.tabs.TabItem
                android:id="@+id/tabItem3"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="TAB03" />

            <com.google.android.material.tabs.TabItem
                android:id="@+id/tabItem4"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="TAB04" />
        </com.google.android.material.tabs.TabLayout>

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.viewpager.widget.ViewPager
        android:id="@+id/viewPager"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

フラグメントのレイアウトファイル等は上記と変わらず。

こちらのコードはgithubからどうぞ

おまけ

layout_scrollFlagsを組み合わせたりすることで以下のような動きにすることができる。(tabLayoutとtoolbarが出てくるタイミングが異なる)
tabLayoutはスクロールしたらすぐに出てくるが、toolbarは一番上までいかないと出てこない。

CollapsingToolbarLayoutとtabLayoutのlayout_scrollFlagsはこんな感じ

activity_main.xml
        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:minHeight="?attr/actionBarSize"
            app:contentScrim="?attr/colorPrimary"
            app:layout_scrollFlags="scroll|enterAlwaysCollapsed"
            app:toolbarId="@+id/toolbar">

            <ImageView
                android:id="@+id/imageView3"
                android:layout_width="match_parent"
                android:layout_height="200dp"
                android:scaleType="centerCrop"
                app:srcCompat="@drawable/house" />

            <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:title="スイスの湖小屋"></androidx.appcompat.widget.Toolbar>
activity_main.xml
        <com.google.android.material.tabs.TabLayout
            android:id="@+id/tabLayout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_collapseMode="parallax"
            app:layout_scrollFlags="scroll|enterAlways"
            app:tabIndicatorFullWidth="false"
            app:tabMode="scrollable">

CoordinatorLayout + FloatingActionButton

スクロールに合わせてボタンが消えるヤツ
FloatingActionButtonのついでに戻るボタンとかオプションメニューとかも表示してみる

レイアウトファイル

FloatingActionButtonを設置する。

キャプチavaャ.PNG  cavc.PNG

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:contentScrim="?attr/colorPrimary"
            app:layout_scrollFlags="scroll|enterAlwaysCollapsed|exitUntilCollapsed"
            app:toolbarId="@+id/toolbar">

            <ImageView
                android:id="@+id/imageView"
                android:layout_width="match_parent"
                android:layout_height="200dp"
                android:scaleType="centerCrop"
                app:layout_collapseMode="parallax"
                app:srcCompat="@drawable/house" />

            <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin"
                app:title="スイスの湖小屋"></androidx.appcompat.widget.Toolbar>
        </com.google.android.material.appbar.CollapsingToolbarLayout>
    </com.google.android.material.appbar.AppBarLayout>

    <androidx.core.widget.NestedScrollView
        android:id="@+id/nestedScrollView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
     app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />
    </androidx.core.widget.NestedScrollView>

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:src="@android:drawable/btn_star_big_off"
        app:layout_anchor="@id/appbar"
        app:layout_anchorGravity="bottom|right" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

toolbarapp:layout_collapseMode属性はpinにしておかないと戻るボタンとかが表示されなくなる。

floatingActionButtonのlayout_anchorを指定することでappbarが隠れたときにfabも非表示にするようになっている。
また、fabの位置はlayout_anchorGravityで指定可能。普通のlayout_gravityとかじゃないので注意。
とりあえずこれでスクロールに合わせて消えるfabの出来上がり。

戻るボタンの表示

後は戻るボタンとかオプションメニューを表示してみる。

menuフォルダを作って適当にmenuItemを追加(わからない方はoptionMenuの表示の仕方とかで調べてみてください。)
onCreateで戻るボタンとオプションメニューを表示させる。

MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        // toolbar設定
        setSupportActionBar(toolbar)
        //戻るボタンの表示
        supportActionBar?.setDisplayHomeAsUpEnabled(true)

        // recyclerView
        val list = List<String>(20) { "RecyclerView$it" }
        val adapter = RecyclerViewAdapter(list)
        recyclerView.adapter = adapter
        recyclerView.layoutManager = LinearLayoutManager(this)
    }

    // オプションメニューを表示させる
    override fun onCreateOptionsMenu(menu: Menu?): Boolean {
        val inflater = MenuInflater(this)
        inflater.inflate(R.menu.main, menu)
        return true
    }

出来上がり。
こちらのコードはgithubからどうぞ

アクションバーを非表示にする

stylesを少しいじってアクションバー非表示

styles.xml
    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
        <item name="windowNoTitle">true</item>
     <!--        アクションバーの文字色-->
        <item name="android:textColorPrimary">@android:color/white</item>
    </style>

アクションバーの設定(※toolbarを自分で作ってそれをアクションバーとして使う場合)
onCreateに以下を加える

MainActivity.kt
setSupportActionBar(toolbar)

おわり

使いづらいと思ってたCoordinatorLayoutとこれを機に仲良くなりたいと思ってまとめてみた。
少しでもアンドロイダーの方々の一助となれば幸いです。
上にスクロールして消えるときにアニメーションとか入れているものを紹介しているサイトがいくつかあって、アニメーションとCoordinatorLayout入れるだけでずいぶん印象変わりそうだなと思った。

スイスにこんな別荘が欲しい...

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Library not loaded: @rpath/Flutter.framework/Flutter

  • 内容:
    Flutterでアプリを作成し、実機で確認をしようとした際に、 下記のエラーが発生したため、実機での確認ができませんでしたので、 情報共有させていただきます。
    ※情報の判断はご自身の責任でお願い致します。

dyld: Library not loaded: @rpath/Flutter.framework/Flutter

  • 環境:
    Xcode 11.3
    iPhoneX(iOS13.3.1)
    iPhone5S(iOS10.3.2)

  • 結果:
    本家サイトに追記がありました。特定の条件の場合、エラーが出てしまうようです。

    • signing identity profile
    • iOS13.3.1
      ですと、ダメなようです。

iPhone5S(iOS10.3.2)では、実機で実行されることを確認致しました。

以上となります。?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

MockRetrofit で異常系のテストを行う時の注意

TL;DR

MockRetrofit で異常系のテストを行う時は、 例外を throw せずに、 BehaviorDelegate#returning にエラー内容を渡す。

※例外を throw すると、UnitTest が中断されてしまいます。

Good

class MockGitHubService(
    private val delegate: BehaviorDelegate<GitHubService>
) : GitHubService {

    override fun listRepos(user: String): Observable<List<Repository>> {
        // 通信失敗を delegate する
        val fakeCall = Calls.response(Response.error<Repository>(404, ResponseBody.create(...)))
        return delegate.returning(fakeCall).listRepos(user)
    }
}

Bad

class MockGitHubService(
    private val delegate: BehaviorDelegate<GitHubService>
) : GitHubService {

    override fun listRepos(user: String): Observable<List<Repository>> {
        // ここで throw すると、UnitTest の実行が中断される
        throw HttpException(Response.error<List<Repository>>(404, ResponseBody.create(...)))
    }
}

背景

  • API の呼び出しを Retrofit & RxJava で行っている
  • 特定の例外なら、成功に丸め込んだり、別の例外を投げ直す

という事を行っており、正常系・異常系の UnitTest を書こうとしていました。

サンプルでおなじみの、GitHub API の呼び出しのコードで書くと、

service.listRepos("octocat")
    .onErrorReturn { throwable ->
        when {
            // 404 なら空 list を返す(成功扱いにする)
            (throwable is HttpException && throwable.code() == 404) -> listOf()
            else -> throw throwable
        }
    }

こんなイメージで、これがテスト対象です。

正常系の MockService が、

override fun listRepos(user: String): Observable<List<Repository>> {
    val fakeResponse = listOf(Repository(...), Repository(...)) // 2 件返す
    return delegate.returningResponse(fakeResponse).listRepos(user)
}

のように記述できるので、 fun listRepos で throw したら、 onErrorReturn にひっかかる と思い込んでました。。。

(テストコードの全体感は、正常系のサンプルが色々出てくるのでそちらを参照下さい。)

まとめ

MockRetrofit で異常系のテストを行う時は、 例外を throw せずに、 BehaviorDelegate#returning にエラー内容を渡す。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Androidデバイス内で起動したvimの為のtips

いつもポエムの様にDroidVimの話を垂れ流す当記事ですが、最近androidからのsshクライアントをTermuxに変更したため、「Termuxでもvimが必要」というケースが増え、vimrc内で条件分岐が欲しいケースがまた増えました。gitを使って共通の設定を使っているので、設定内で複雑な記述を避けるために環境(と端末)を特定する方法が必要なわけですが…

出来らぁ!

とはいえ、とりあえずDroidVimとTermuxのケースに絞ります。他の方法は非現実的でしょうし、UserLAndなら逆にこんなことで困りません。この2つのアプリで共通するのは、android内の/data/dataディレクトリにアプリ専用のホームディレクトリがあるということです。つまりecho expand('~')で現在のアプリのホームディレクトリのフルパスが取得できます。というわけで…

detectAppSample.vim
if expand('~') =~? 'droidvim'
    echo "I'm DroidVim."
elseif expand('~') =~? 'termux'
    echo "I'm Termux."
endif

が出来てしまいます。これで片方は解決です。もう一つ、複数のandroidデバイスをどう識別するかですが、adbから使えるgetpropが難なく使えました。というわけで…

detectDeviceSample.vim
let device = system('getprop ro.product.model')

で問題なしです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AndroidのTextViewで省略表示(マーキー、...)

TextViewの省略表示(マーキーや...)について調べました。

layout xmlで設定する場合

android:ellipsize属性を設定することで、ビューの幅よりも長い文字の場合に省略表示されます。

layout.xml
    <TextView
        android:id="@+id/text2"
        android:focusable="true"
        android:ellipsize="none"
        android:layout_width="match_parent"
        android:layout_height="30dp"
        android:gravity="center_vertical"
        android:singleLine="true"
        android:text="1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890" />
Constant Description Example
none 省略なし 1234567
start 先頭を省略 …456789
middle 中央を省略 123…789
end 最後を省略 123456…
marquee スクロール

marqueeについては、TextViewにフォーカスがあたった際にスクロールが実行されます。

setEllipsize methodで設定する場合

setEllipsizeでも設定することができます。

public void setEllipsize (TextUtils.TruncateAt where)

Parameters Description Example
null 省略なし 1234567
START 先頭を省略 ...456789
MIDDLE 中央を省略 123...789
END 最後を省略 123456...
MARQUEE スクロール
END_SMALL 最後を省略 1234567..

※END_SMALLはSdkVersion 28からとなります。

marqueeの注意点

marqueeについては、TextViewにフォーカスがあたった際にスクロールが実行されます。
TextViewにフォーカスをあてられない場合(例:RecyclerViewでアイテムに複数のTextViewがある場合)はsetSelectedをtrueにする必要があります。

サンプルコード

例:RecyclerViewでアイテムに複数のTextViewがある場合

TextViewSampleActivity.kt
class TextViewSampleActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_text_view_sample)

        val recyclerView = findViewById<RecyclerView>(R.id.list)
        recyclerView.adapter = TextViewSampleAdapter(this)
        val layoutManager = LinearLayoutManager(this)
        recyclerView.layoutManager = layoutManager
        recyclerView.addItemDecoration(DividerItemDecoration(this, layoutManager.orientation))
    }
}
activity_text_view_sample.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:focusable="true"
    android:orientation="vertical">

    <TextView
        android:id="@+id/ellipsize"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <TextView
        android:id="@+id/targetText"
        android:ellipsize="none"
        android:layout_width="match_parent"
        android:layout_height="30dp"
        android:singleLine="true"
        android:text="1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890" />
</LinearLayout>

ポイントは、RecyclerViewのアイテムにフォーカスがあたった際に、isSelectedをtrueにすることです。

TextViewSampleAdapter.kt
class TextViewSampleAdapter(
        context: Context) : RecyclerView.Adapter<TextViewSampleAdapter.ListAdapterHolder>() {
    private val inflater = LayoutInflater.from(context)

    private val ID_NONE = 0
    private val ID_START = 1
    private val ID_MIDDLE = 2
    private val ID_END = 3
    private val ID_MARQUEE = 4
    private val ID_END_SMALL = 5
    private val ID_ARRAY = arrayOf(ID_NONE, ID_START, ID_MIDDLE, ID_END, ID_MARQUEE, ID_END_SMALL)

    class ListAdapterHolder(view: View) : RecyclerView.ViewHolder(view)

    override fun getItemCount(): Int {
        return ID_ARRAY.size
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListAdapterHolder {
        val view = inflater.inflate(R.layout.list_adapter_item, parent, false)
        return ListAdapterHolder(view)
    }

    override fun onBindViewHolder(holder: ListAdapterHolder, position: Int) {
        holder.itemView.setOnFocusChangeListener { v, hasFocus ->
            if (hasFocus) {
                if (position == ID_MARQUEE) holder.itemView.targetText.isSelected = true
            } else {
                if (position == ID_MARQUEE) holder.itemView.targetText.isSelected = false
            }
        }
        when (position) {
            ID_NONE -> setEllipsize(holder, "ellipsize NONE", null)
            ID_START -> setEllipsize(holder, "ellipsize START", TextUtils.TruncateAt.START)
            ID_MIDDLE -> setEllipsize(holder, "ellipsize MIDDLE", TextUtils.TruncateAt.MIDDLE)
            ID_END -> setEllipsize(holder, "ellipsize END", TextUtils.TruncateAt.END)
            ID_MARQUEE -> setEllipsize(holder, "ellipsize MARQUEE", TextUtils.TruncateAt.MARQUEE)
            else -> setEllipsize(holder, "ellipsize END_SMALL", TextUtils.TruncateAt.END_SMALL)
        }
    }

    private fun setEllipsize(holder: ListAdapterHolder, text: String, where: TextUtils.TruncateAt?) {
        holder.itemView.ellipsize.setText(text)
        holder.itemView.targetText.ellipsize = where
    }
}
list_adapter_item.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:focusable="true"
    android:orientation="vertical">

    <TextView
        android:id="@+id/ellipsize"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <TextView
        android:id="@+id/targetText"
        android:ellipsize="none"
        android:layout_width="match_parent"
        android:layout_height="30dp"
        android:singleLine="true"
        android:text="1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890" />
</LinearLayout>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ドラッグで始める最速Coordinatorlayout

最初に一言

スクロールに合わせてアクションバーとか消したいと思ってCoordinatorlayout使おうとして、「これめちゃくちゃ使いづらい(泣)、そもそもCoordinatorlayoutってどんなレイアウトよ?,linearなのか?constraintなのか?、Viewがなんか重なるし、スクロールしても消えないし...」って思いをしたことがある人は自分だけじゃないはず。

レイアウトのネスト構造が分かりづらいCollapsingLayout...
Coordinatorlayout調べてもandroid xに対応した記事が少ない...

今回はそんな体験を踏まえてCoordinatorlayoutの始め方を書いておこうと思った。

Coordinatorlayoutって?

これを使えばどんなことができるのか?
↓こういうよく見るスクロールに合わせて消えるヤツ

誰でもできるドラッグ実装!

ほぼドラッグで冒頭のスクロールで消えるヤツ作っていく!
android studio のUIからドラッグでできるようになっている。(多分最近のアップデートのおかげ(未確認))
最初にプロジェクト作ったら↓みたいな感じになってる。
キャddcプチャ.PNG

ここでパレットペインからAppBarLayoutをconstraintLayoutとかの適当な場所にドラッグする。
すると↓のようなポップアップが表示される。
キャプチccャ.PNG
これがめちゃくちゃ便利!!

とりあえずCollapsing Toolbarにチェックを入れてOKクリック。

キャプcccfaチャ.PNG
こんな感じに自動生成される。

ここで実行してみると↓みたいな感じの動きになる。

おぉ、なんかそれっぽいぞ。雰囲気がある。

ベースはこれで完成
ここからtoolbarをなくす場合は非表示にしたり、画像載せたり、ViewPager入れたりとかしてカスタマイズしていく。

ここからはドラッグで生成されたToolbarを使う場合を想定する。
ドラッグで自動生成されたtoolbarを使う場合はデフォルトで存在するtoolbarを消して自動生成されたtoolbarを設定する必要がある。

そこでstylesを以下のように書き換えるてアクションバーを非表示にする

styles.xml
    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
        <item name="windowNoTitle">true</item>
     <!--        アクションバーの文字色-->
        <item name="android:textColorPrimary">@android:color/white</item>
    </style>

次にアクションバーの設定
onCreateに以下を加える

MainActivity.kt
setSupportActionBar(toolbar)

画像を表示する
レイアウトにImageViewを載せてapp:layout_collapseMode="parallax"属性を追加する。
また、toolbarにタイトルを設定する。
最後にCollapsingToolbarLayoutの属性にapp:layout_scrollFlags="scroll|exitUntilCollapsed"を追加する。

cccccキャプチャ.PNG  キャプチccgasdャ.PNG

一応レイアウトファイルはこんな感じ

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appbar"
        android:layout_width="match_parent"
        android:layout_height="192dp">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:contentScrim="?attr/colorPrimary"
            app:layout_scrollFlags="scroll|exitUntilCollapsed"
            app:toolbarId="@+id/toolbar">

            <ImageView
                android:id="@+id/imageView"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:scaleType="centerCrop"
                app:srcCompat="@drawable/house" />

            <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:title="スイスの湖小屋"></androidx.appcompat.widget.Toolbar>
        </com.google.android.material.appbar.CollapsingToolbarLayout>
    </com.google.android.material.appbar.AppBarLayout>

    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            tools:context=".MainActivity">

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Hello World!"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintLeft_toLeftOf="parent"
                app:layout_constraintRight_toRightOf="parent"
                app:layout_constraintTop_toTopOf="parent" />
        </androidx.constraintlayout.widget.ConstraintLayout>
    </androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

実行してみると...

完成!!

ちなみにCollapsingToolbarLayoutの属性をapp:layout_scrollFlags="scroll"に変更すると下のようにアクションバーも隠れるようになる。

おわり

Coordinatorlayoutは正直苦手意識が強かったけどUI使って簡単に実装出来た。
中身を色々変えてアプリに組み込めそう...

今回の冒頭のrecyclerView入りはgithubに挙げてあります。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ドラッグで始める最速CoordinatorLayout

最初に一言

スクロールに合わせてアクションバーとか消したいと思ってCoordinatorLayout使おうとして、「これめちゃくちゃ使いづらい(泣)、そもそもCoordinatorLayoutってどんなレイアウトよ?,linearなのか?constraintなのか?、Viewがなんか重なるし、スクロールしても消えないし...」って思いをしたことがある人は自分だけじゃないはず。

レイアウトのネスト構造が分かりづらいCollapsingLayout...
CoordinatorLayout調べてもandroid xに対応した記事が少ない...

今回はそんな体験を踏まえてCoordinatorLayoutの始め方を書いておこうと思った。

CoordinatorLayoutって?

これを使えばどんなことができるのか?
↓こういうよく見るスクロールに合わせて消えるヤツ

誰でもできるドラッグ実装!

ほぼドラッグで冒頭のスクロールで消えるヤツ作っていく!
android studio のUIからドラッグでできるようになっている。(多分最近のアップデートのおかげ(未確認))
最初にプロジェクト作ったら↓みたいな感じになってる。
キャddcプチャ.PNG

ここでパレットペインからAppBarLayoutをconstraintLayoutとかの適当な場所にドラッグする。
すると↓のようなポップアップが表示される。
キャプチccャ.PNG
これがめちゃくちゃ便利!!

とりあえずCollapsing Toolbarにチェックを入れてOKクリック。

キャプcccfaチャ.PNG
こんな感じに自動生成される。

ここで実行してみると↓みたいな感じの動きになる。

おぉ、なんかそれっぽいぞ。雰囲気がある。

ベースはこれで完成
ここからtoolbarをなくす場合は非表示にしたり、画像載せたり、ViewPager入れたりとかしてカスタマイズしていく。

ここからはドラッグで生成されたToolbarを使う場合を想定する。
ドラッグで自動生成されたtoolbarを使う場合はデフォルトで存在するtoolbarを消して自動生成されたtoolbarを設定する必要がある。

そこでstylesを以下のように書き換えてアクションバーを非表示にする

styles.xml
    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
        <item name="windowNoTitle">true</item>
     <!--        アクションバーの文字色-->
        <item name="android:textColorPrimary">@android:color/white</item>
    </style>

次にアクションバーの設定
onCreateに以下を加える

MainActivity.kt
setSupportActionBar(toolbar)

画像を表示する
レイアウトにImageViewを載せてapp:layout_collapseMode="parallax"属性を追加する。
また、toolbarにタイトルを設定する。
最後にCollapsingToolbarLayoutの属性にapp:layout_scrollFlags="scroll|exitUntilCollapsed"を追加する。

cccccキャプチャ.PNG  キャプチccgasdャ.PNG

一応レイアウトファイルはこんな感じ

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appbar"
        android:layout_width="match_parent"
        android:layout_height="192dp">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:contentScrim="?attr/colorPrimary"
            app:layout_scrollFlags="scroll|exitUntilCollapsed"
            app:toolbarId="@+id/toolbar">

            <ImageView
                android:id="@+id/imageView"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:scaleType="centerCrop"
                app:layout_collapseMode="parallax"
                app:srcCompat="@drawable/house" />

            <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:title="スイスの湖小屋"></androidx.appcompat.widget.Toolbar>
        </com.google.android.material.appbar.CollapsingToolbarLayout>
    </com.google.android.material.appbar.AppBarLayout>

    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">

        <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            tools:context=".MainActivity">

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Hello World!"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintLeft_toLeftOf="parent"
                app:layout_constraintRight_toRightOf="parent"
                app:layout_constraintTop_toTopOf="parent" />
        </androidx.constraintlayout.widget.ConstraintLayout>
    </androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

実行してみると...

完成!!

ちなみにCollapsingToolbarLayoutの属性をapp:layout_scrollFlags="scroll"に変更すると下のようにアクションバーも隠れるようになる。

おわり

CoordinatorLayoutは正直苦手意識が強かったけどUI使って簡単に実装出来た。
中身を色々変えてアプリに組み込めそう...

今回の冒頭のrecyclerView入りはgithubに挙げてあります。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む