20201212のAndroidに関する記事は13件です。

Android studio:Kotlin:shared preferenceの使い方

アプリ版のini設定みたいなやつ

とりあえずこれを書く

MainActivity.kt
//第一引数に名前を入れとくとアプリ全体で共有できる
val spf = getSharedPreferences("Setting", MODE_PRIVATE)
val editor = spf.edit()

読み込みはこう(デフォルト値は必須)

MainActivity.kt
spf.getInt("Key", "ここにデフォルトの値を入れる")
spf.getString("Key", "ここにデフォルトの値を入れる")
spf.getFloat("Key", "ここにデフォルトの値を入れる")

//こんな感じで使う
var A=spf.getInt("Key", "ここにデフォルトの値を入れる")

書き込みはこう

MainActivity.kt
editor.putInt("Key",1)
editor.putString("Key","内容")
editor.putFloat("Key",1F)
//applyしたら書き込まれる
editor.apply()

一度全削除してインストール直後の状態を試したい場合は以下

MainActivity.kt
val spf = getSharedPreferences("Setting", MODE_PRIVATE)
val editor = spf.edit()
editor.clear()
editor.apply()
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Android】備忘録: 権限リクエストのやり方

【Android】備忘録: 権限リクエストの方法

概要

完全な自分用の備忘録です。
毎回、パーミションのリクエストの仕方を忘れるので、その記録です。

手順

  1. manifestに、権限の宣言を追加する。
  2. 実行時に、permissionの状態を確認する
  3. 許可されていなければ、リクエストを行う
  4. リクエストの結果で処理を実行

1. manifestに、権限の宣言を追加する。

  • AndroidManifest.xmlに、権限についての宣言を行う。

    • 例: カメラを使いたい時
  • <users-permission android:name="~"/> を追加

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
  <manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.sample.sample">

    // この部分が必要
    <uses-permission android:name="android.permission.CAMERA" />

  <application
     ~~~省略~~~ >

  </aplication>
</manifest>

2. 実行時に、permissionの状態を確認する

  • 例えば、buttonをタップした時に、permissionの確認をする場合

    • 例: cameraButtonをタップした時に、確認
  • 注意するところ

    • Manifestが、java.util.Manifestをimportしないこと。android.Manifestをimportする。
~Activity.kt
val cameraButton = binding.camera
cameraButton.setOnClickListener {
  // この部分が必要
  // checkSelfPermissionで、Permissionの状態を確認する
  // != PackageManager.PERMISSION_GRANTEDなので、許可されていないという条件分岐
  if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
   // この部分は、「3. 許可されていなければ、リクエストを行う」で追加
  } else {
    // 許可されていれば、カメラ起動
    startActivity(Intent.(MediaStore.ACTION_IMAGE_CAPTURE))
  }
}

3. 許可されていなければ、リクエストを行う

  • 2.で書いたコードに、リクエストの処理を追加
~Activity.kt
cameraButton.setOnClickListener {
  // != PackageManager.PERMISSION_GRANTEDなので、許可されていないという条件分岐
  if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
   // この部分が、追加
    // 最後のREQUEST_CAMERA_PERMISSIONは、任意の定数
    ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.CAMERA), REQUEST_CAMERA_PERMISSION)
  } else {
    // 許可されていれば、カメラ起動
    startActivity(Intent.(MediaStore.ACTION_IMAGE_CAPTURE))
  }
}

4. リクエストの結果で処理を実行

  • permissionのリクエストの結果は、onRequestPermissionsResultで受け取る
~Activity.kt
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
  if (requestCode == REQUEST_CAMERA_PERMISSION) {
      if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
          // カメラ起動
          startActivity(Intent.(MediaStore.ACTION_IMAGE_CAPTURE))
      }
      super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}

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

Dromiを使いProxy下のAndroid端末でLINEを使う

Dromiとは

HTTP, Socks ProxyをVPNとして接続するアプリです。
https://play.google.com/store/apps/details?id=org.sandrob.drony&hl=ja&gl=US

環境

  • 認証付きProxy必須環境
  • 22番ポートはProxyなしで通信可能
  • AzureにSquidでHTTP proxyを構築済み

叩くコマンド

# 外のProxyサーバで行う
/etc/ssh/sshd_configの
GatewayPorts
をyesにする

# APにするPCで行う
ssh -gNfL 0.0.0.0:8888:localhost:8080 username@ipadress
sudo create_ap [無線LANデバイス名] [共有元デバイス名] --no-dnsmasq -d --ieee80211n --redirect-to-localhost [SSID] [パスワード] &
ip a (ap0のIPアドレスを確認する)

主な流れ

  1. ssh -gNfL 0.0.0.0:8888:localhost:8080 username@ipadressで0.0.0.0へAzureのHTTP proxyを持ってくる
  2. create_apを活用して無線APを建てる
  3. スマホでWi-Fiに接続する  このとき,Proxyにはスマホ側のデフォルトゲートウェイをアドレスに設定します。
  4. Deoniで先程と同様にProxyの設定をする

create_apについて

実行してできる仮想ネットワークデバイスのIPアドレスは固定になります。
それがスマホ側のデフォルトゲートウェイになります。

Deoniの設定

google, ブラウザ, Twitterなど元からHTTP proxyに対応しているアプリはDirect設定にすると正常に通信可能です。
LINE, Discord, InstagramなどHTTP proxyに非対応のアプリはVPN経由で通信しようとし,結果的にProxyを通せます。

犠牲にしたもの

VPNに繋がってしまう関係でKDE Connectが死にました。

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

Dronyを使いProxy下のAndroid端末でLINEを使う

Dronyとは

HTTP, Socks ProxyをVPNとして接続するアプリです。
https://play.google.com/store/apps/details?id=org.sandrob.drony&hl=ja&gl=US

環境

  • 認証付きProxy必須環境
  • 22番ポートはProxyなしで通信可能
  • AzureにSquidでHTTP proxyを構築済み

叩くコマンド

# 外のProxyサーバで行う
/etc/ssh/sshd_configの
GatewayPorts
をyesにする

# APにするPCで行う
ssh -gNfL 0.0.0.0:8888:localhost:8080 username@ipadress
sudo create_ap [無線LANデバイス名] [共有元デバイス名] --no-dnsmasq -d --ieee80211n --redirect-to-localhost [SSID] [パスワード] &
ip a (ap0のIPアドレスを確認する)

主な流れ

  1. ssh -gNfL 0.0.0.0:8888:localhost:8080 username@ipadressで0.0.0.0へAzureのHTTP proxyを持ってくる
  2. create_apを活用して無線APを建てる
  3. スマホでWi-Fiに接続する  このとき,Proxyにはスマホ側のデフォルトゲートウェイをアドレスに設定します。
  4. Deoniで先程と同様にProxyの設定をする

create_apについて

実行してできる仮想ネットワークデバイスのIPアドレスは固定になります。
それがスマホ側のデフォルトゲートウェイになります。

Dronyの設定

google, ブラウザ, Twitterなど元からHTTP proxyに対応しているアプリはDirect設定にすると正常に通信可能です。
LINE, Discord, InstagramなどHTTP proxyに非対応のアプリはVPN経由で通信しようとし,結果的にProxyを通せます。

犠牲にしたもの

VPNに繋がってしまう関係でKDE Connectが死にました。

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

Androidでもリアルタイムにブラーをかける

最近はWebページでもブラー(ぼかし)エフェクトを使用したサイトがみられるようになりましたね(今からだと旬は過ぎた感ありますが…)。
iOSだと6年前のiOS 8から UIVisualEffectView という便利なビューが標準で用意されたので、ミニマムだとレイアウトだけで実現できてしまいます。

ios.gif

Layout風に書くとこんな感じ…

<UIView> <!-- UIViewはViewGroupのように子ビューを複数持てる -->
  <UIScrollView>
    <UIImageView>
  </UIScrollView>
  <UIVisualEffectView /> <!-- Androidと同じく下に書くと階層的には上に来て下にあるビューにブラーがかかる -->
</UIView>

最近Webでも backdrop-filter によってIEを除いた最新のブラウザで使えるようになりました:

web.gif

雑ですが iOS のサンプルを大まかに再現するとこうなります:

ちょっと長いので省略
<html>
  <head>
    <style type="text/css">
      img {
        object-fit: cover;
        width: 100%;
        height: 200vh;
      }

      .overlay {
        position: fixed;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        display: flex;
      }

      .box {
        margin: auto;
        width: 75vw;
        height: 75vw;
        backdrop-filter: blur(24px);
        -webkit-backdrop-filter: blur(24px); /* Safari */
       background-color: #ffffff5f;
     }
    </style>
  </head>

  <body>
    <img src="...">
    <div class="overlay">
      <div class="box"></div>
    </div>
  </body>
</html>

本題

画像やビューのキャプチャに対してブラーをかける方法についてはすでに 記事
質問 がありましたが、スクロールなどリアルタイムにかける方法についてはまだ情報がまとまっていなさそうだったので、実現するライブラリについていろいろ書いていきたいと思います。

ちなみに

  • 今回のような例であれば上記ライブラリであらかじめビットマップを作っておいてスクロールに合わせて動かせば擬似的に見せることは可能でした(発想の転換)
  • 静止画に対するブラーの掛け方は RenderScript から CPU で Bitmap を直接触る方法などいろいろありますが、興味深いことに StackOverflow でパフォーマンスを測定した方がおられたので共有しておきます

RealtimeBlurView

ずばりな名前のライブラリでレイアウトに乗っけるだけで背景のビューにブラーがかかってくれるすごいライブラリです。説明に書いてある通りにするだけで確かに実現ができました:

android.gif

サンプルコードはこちらです: https://github.com/cubenoy22/android-reatime-blur

どうやって実現しているのか

ただライブラリを紹介するだけではつまらないのでちょっと中身をみていきたいと思います。

RealtimeBlurView

XMLで配置するビューの実装から見ていきます。対象ファイルは こちら

ブラー用のビットマップ作成

prepare() メソッドでメンバーの mBitmapToBlur をビューの大きさ分で作成していて、描画用の mBlurringCanvas も同時に作成しています。このメソッドは後ほど紹介する onPreDraw 内で呼ばれる ようになっていて、画面の更新がされる直前かつまだ準備できていなければ実行されるようになっています。ビューのサイズ変更もここで対応できるようになっていました。

protected boolean prepare() {
    // 省略
    int scaledWidth = Math.max(1, (int) (width / downsampleFactor));
    int scaledHeight = Math.max(1, (int) (height / downsampleFactor));

    if (mBlurringCanvas == null || mBlurredBitmap == null
        || mBlurredBitmap.getWidth() != scaledWidth
        || mBlurredBitmap.getHeight() != scaledHeight) {
        mBitmapToBlur = Bitmap.createBitmap(scaledWidth, scaledHeight, Bitmap.Config.ARGB_8888);
    }
    mBlurringCanvas = new Canvas(mBitmapToBlur);
    mBlurredBitmap = Bitmap.createBitmap(scaledWidth, scaledHeight, Bitmap.Config.ARGB_8888);
}

ブラーが必要なエリアをビットマップに転写

Activity の Window の decorView に対して

private final ViewTreeObserver.OnPreDrawListener preDrawListener = new ViewTreeObserver.OnPreDrawListener() {
    @Override
    public boolean onPreDraw() {
        // 省略
        if (decor != null && isShown() && prepare()) {
            if (decor.getBackground() != null) {
                decor.getBackground().draw(mBlurringCanvas);
            }
            decor.draw(mBlurringCanvas);
            blur(mBitmapToBlur, mBlurredBitmap);
        }
    }
}

これであらかじめ作っておいた Bitmap (mBitmapToBlur) に対してビューの内容が転写されました。面白い。

転写した Bitmap に対してブラーをかける

直前の preDrawListener での一連の流れでブラーもかけています。 mBitmapToBlur の他にもう一つ mBlurredBitmap という Bitmap が用意されています。このビットマップは実は mBitmapToBlur同時に同じサイズで用意 されていました。上のコードにも書いておきましたが blur() 関数ですね。

protected void blur(Bitmap bitmapToBlur, Bitmap blurredBitmap) {
    mBlurImpl.blur(bitmapToBlur, blurredBitmap);
}

mBlurImpl ( BlurImpl ) は getBlurImpl メソッドで状況に応じた実装が使用されるようになっており、違いは RenderScript のパッケージが各種用意されているだけのようでした:

ScriptIntrinsicBlur を使って mBitmapToBlur の内容にブラーを mBlurrerdBitmap に出力させていることがわかります。

@Override
public void blur(Bitmap input, Bitmap output) {
    mBlurInput.copyFrom(input);
    mBlurScript.setInput(mBlurInput);
    mBlurScript.forEach(mBlurOutput);
    mBlurOutput.copyTo(output);
}

これで表示するための準備ができあがりました。

onDraw で mBlurredBitmap を描画する

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    drawBlurredBitmap(canvas, mBlurredBitmap, mOverlayColor);
}

ブラーをかけた Bitmap を表示すれば完了となるわけですね!

BlurKit

こちらの方がスター数が多いですが

という理由から個人的に RealtimeBlurView をオススメしたいです。ちなみにブラーをかけるところは ほぼ同じ でビットマップをひとつだけしか使用しないか二つ使用するかが唯一異なる点でした(RealtimeBlurViewのビットマップが二つ必要な理由についてはまだ調査していません)。

(ちょこっと追記) ほぼ同じって書きましたが RenderScript のコードも改めてみてみると毎回初期化していてここも効率化できそう。。

BlurView

こちらはレイアウト以外にコードを書く必要があるみたいです。コードは読みやすい気がします。

全体的に RealtimeBlurView と同じような実装で設計と RenderScript のビットマップを2つ使用するかどうかの違いしかなさそうでした。気軽に使用できる点からやはり RealtimeBlurView をおすすめしたいです。RealtimeBlurViewのビットマップが二重になっているところが改善されたら今のところ最強でしょうかね(プルリク出してみようかしら…)。

終わりに

Androidのライブラリ3つを紹介しましたがいずれもビットマップを用意したりしています。おそらくiOSやWebではGPUのレンダリングパイプラインあたりだけで実現している気がしていて、Androidでもそういった高効率のアプローチができないか調査する余地がありそう、という感想を持ちました。できればGoogle純正のイケてるライブラリが出てくれると嬉しかったり。

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

Xamarin.FormsでAndroidのデバイスの設定の影響でアプリのフォントサイズが変わらないようにする方法

はじめに

Androidは、下図のような画面で、デバイスのフォントサイズを変更できます。
これは、アプリのフォントサイズに影響するため、普通にXamarin.Formsでアプリを開発すると、フォントサイズがこの設定の影響で変わってしまいます。それを変わらないようにする方法の紹介です。

実装方法

Androidプロジェクトにある MainActivity.cs に以下のコードブロックを追加することで、デバイスのフォントサイズに影響しなくなります。

MainActivity.cs

public override Android.Content.Res.Resources Resources
{
    get
    {
        Android.Content.Res.Resources res = base.Resources;
        Configuration config = new Configuration();
        config.SetToDefaults();
        res?.UpdateConfiguration(config, res.DisplayMetrics);
        return res;
    }
}

ただし、上記で利用している Resources.UpdateConfigurationメソッドは、Android API 25(Android7.1)以上では、非推奨となっています。
詳しくは、以下に書いてあります。
Resources | Android Developers

従って、Android API 25(Android7.1)以降と、それより前で分岐して、別々の方法で実装する必要があります(自分で確認した限り、新しい方式の実装は、Android API 24(Android7.0)では、正しく動作しませんでした)。

public override Android.Content.Res.Resources Resources
{
    get
    {
        if (DeviceInfo.Version.Major < 7 ||
            (DeviceInfo.Version.Major == 7 && DeviceInfo.Version.Minor == 0))
        {
            // Android API 24(Android7.0)以前は古い方式で実装
            Android.Content.Res.Resources res = base.Resources;
            Configuration config = new Configuration();
            config.SetToDefaults();
            res?.UpdateConfiguration(config, res.DisplayMetrics);
            return res;
        }
        else
        {
            // Android API 25(Android7.1)以降は新しい方式で実装
            var config = new Configuration();
            config.SetToDefaults();
            return CreateConfigurationContext(config)?.Resources;
        }
    }
}

古い方式と新しい方式のそれぞれの実装方法は、以下の stack overflow の記事を参考にしています。
How to Prevent Device font size effect of Xamarin android app?

まとめ

Xamarin.FormsはC#でWindowsとAndroidとiOSのアプリがまとめて開発できて便利です。

ちなみに私は、普段はエンジニアリングマネージャーとして、チームの皆で楽しく開発する施策を色々実施しています。詳しくは以下を参照ください。
1年以上かけて生産性倍増+成長し続けるチームになった施策を全部公開

Twitterでも開発に役立つ情報を発信しています → @kojimadev

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

【Flutter】遭遇したエラー&&解決策まとめ

参考文献

Podライブラリの警告

◆ エラー内容

The iOS Simulator deployment target ‘IPHONEOS_DEPLOYMENT_TARGET’ is set to 8.0, but the range of supported deployment target versions is 9.0 to 14.0.99.

◆ 解決策

Podfile
post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '9.0'
    end
  end
end
$ pod update

GoogleDataTransportエラー

◆ エラー内容

error: umbrella header for module 'GoogleDataTransport' does not include header 'GDTCORDataFuture.h'

◆ 解決策

$ rm -rf Pods
$ flutter clean
$ flutter run //pod installが自動実行される

「firebase_core_web not found」エラー

◆ エラー内容

Plugin project :firebase_core_web not found. 
Please update settings.gradle.

◆ 解決策

setting.gradle
def flutterProjectRoot = rootProject.projectDir.parentFile.toPath()

def plugins = new Properties()
def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins')
if (pluginsFile.exists()) {
    pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) }
}

plugins.each { name, path ->
    def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile()
    include ":$name"
    project(":$name").projectDir = pluginDirectory
}

「Firebase.initializeApp」エラー

◆ エラー内容

No Firebase App '[DEFAULT]' has been created - call Firebase.initializeApp()

◆ 解決策

Widget _buildBody(BuildContext context) {
    Firebase.initializeApp(); // new
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

PCでAndoroidを動かす【草案中】

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

【Ktolin初心者】ProgressBarを実装してみた

はじめに

よくある画面読み込み中にあるリクエストAPIクリエストの処理待ちに使えわれるProgressBarについて解説してみます。

この記事書いてる人

株式会社evoluのAndroidエンジニア(3ヶ月目)

イメージ

progress barの実装デモ画面になります
こんな感じで読み込みが終わると次の画面が表示されます

コード

MainActivity.kt
package com.example.progressbar

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.ProgressBar

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val progress = findViewById<ProgressBar>(R.id.progress_bar)

    }
}
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"
    tools:context=".MainActivity">

    <ProgressBar
        android:id="@+id/progress_bar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

これでひとまずActivityにProgressBarを表示することができます

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

【Flutter】 iphoneのようにボトムナビゲーションを作る

Flutterでの下タブでのナビゲーションについてです。

マテリアルデザインであればそのままBottomNavigationBarを使用すればいいのですがその場合
画面遷移で下タブが消えてしまうので遷移後に別タブの画面切り替えができません。

今回の記事はiosのように下タブを残したまま画面遷移をさせる方法についてです。
以下を記述しています。

  • CupertinoTabScaffoldを使用しながら下タブを表示させずに画面遷移する方法
  • androidのバックキーを押下した際に現在のタブから前画面に戻る方法

CupertinoTabScaffoldを使用しながら下タブを表示させずに画面遷移する方法

画面遷移する際にrootNavigatorをtrueにしてpushします。

Navigator.of(context, rootNavigator: true).push(
   MaterialPageRoute(
      builder: (context) => nextPage(),
   ),
);

androidのバックキー押下した際に現在のタブから前画面に戻る方法

上記のpushのように下タブを表示せずに画面遷移した場合は問題ありませんが
CupertinoTabScaffoldを使用して下タブを表示しながら画面遷移をした場合androidでバックキーを押すとアプリが閉じます。
これはバックキーでpopの処理が行われているのですが
表示されている画面のcontextではなく、CupertinoTabScaffoldを使用した親画面のcontextを用いて
popしているため親画面は画面遷移していないのでアプリが閉じています。
※iOSの場合は特に何もしなくても画面を左スワイプで前画面に戻ることが可能です。

方法としてはstaticで各タブのcontextを保持しておき、バックキーを押されたタイミングで親画面から各タブのcontextを呼び出しpopします。

/// androidのバックキー制御するための各タブ分のcontext保持クラス
class ConstantsChildContext {
  static int selectedIndex = 0;
  static BuildContext childContext0;
  static BuildContext childContext1;
  static BuildContext childContext2;
}

各タブのbuild時にcontextを初回だけセットします。

/// 初回に各タブのChildContextをセットする
void setChildContext({@required BuildContext childContext}) {
  switch (ConstantsChildContext.selectedIndex) {
    case 0:
      if (ConstantsChildContext.childContext0 == null) {
        ConstantsChildContext.childContext0 = childContext;
      }
      break;
    case 1:
      if (ConstantsChildContext.childContext1 == null) {
        ConstantsChildContext.childContext1 = childContext;
      }
      break;
    case 2:
      if (ConstantsChildContext.childContext2 == null) {
        ConstantsChildContext.childContext2 = childContext;
      }
      break;
  }
}

最後に親画面でCupertinoTabScaffoldの親WidgetとしてWillPopScopeを書き、
ここでバックキーを押した際のイベントを取得します。

     @override
  Widget build(BuildContext context) {
    /// WillPopScopeで親画面のbackKeyイベントを取得し、現在のタブのcontextをpopして前画面に戻る
    return WillPopScope(
      onWillPop: () async {
        /// バックキー押下時のイベント取得
        return _onBackKeyAndroid();
      },
      child: CupertinoTabScaffold(),
    );
  }

各タブで前画面に戻れるかどうかを判定しbool値をかえします。

 /// バックキーをタップすると各画面のpopを実行(Androidのみ)
  bool _onBackKeyAndroid() {
    if (Platform.isAndroid) {
      switch (ConstantsChildContext.selectedIndex) {
        case 0:
          if (Navigator.canPop(ConstantsChildContext.childContext0)) {
            Navigator.pop(ConstantsChildContext.childContext0);
          } else {
            /// 前画面に戻れない場合にアプリを閉じたくなければここはfalse
            return true;
          }
          break;
        case 1:
          if (Navigator.canPop(ConstantsChildContext.childContext1)) {
            Navigator.pop(ConstantsChildContext.childContext1);
          } else {
            return true;
          }
          break;
        case 2:
          if (Navigator.canPop(ConstantsChildContext.childContext2)) {
            Navigator.pop(ConstantsChildContext.childContext2);
          } else {
            return true;
          }
          break;
      }
    }
    return false;
  }

これでandroidのバックキー制御ができるようになりました。

こちらで勉強しました。
サンプルコードのデザインはそのままです。

サンプルコード

main.dart
import 'package:flutter/material.dart';

import 'home_page.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: HomePage(),
    );
  }
}

home_page.dart
import 'dart:io';

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

import 'constants_child_context.dart';
import 'custom_page.dart';

class HomePage extends StatefulWidget {
  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {

  static List<Widget> _pageList = [
    CustomPage(
      pannelColor: Colors.cyan,
      title: 'Home',
      pageCount: 1,
    ),
    CustomPage(
      pannelColor: Colors.green,
      title: 'Settings',
      pageCount: 1,
    ),
    CustomPage(
      pannelColor: Colors.pink,
      title: 'Search',
      pageCount: 1,
    )
  ];

  void _onItemTapped(int index) {
    setState(() {
      ConstantsChildContext.selectedIndex = index;
    });
  }

  /// バックキーをタップすると各画面のpopを実行(Androidのみ)
  bool _onBackKeyAndroid() {
    if (Platform.isAndroid) {
      switch (ConstantsChildContext.selectedIndex) {
        case 0:
          if (Navigator.canPop(ConstantsChildContext.childContext0)) {
            Navigator.pop(ConstantsChildContext.childContext0);
          } else {
            /// 前画面に戻れない場合にアプリを閉じたくなければここはfalse
            return true;
          }
          break;
        case 1:
          if (Navigator.canPop(ConstantsChildContext.childContext1)) {
            Navigator.pop(ConstantsChildContext.childContext1);
          } else {
            return true;
          }
          break;
        case 2:
          if (Navigator.canPop(ConstantsChildContext.childContext2)) {
            Navigator.pop(ConstantsChildContext.childContext2);
          } else {
            return true;
          }
          break;
      }
    }
    return false;
  }

  @override
  Widget build(BuildContext context) {
    /// WillPopScopeで親画面のbackKeyイベントを取得し、現在のタブのcontextをpopして前画面に戻る
    return WillPopScope(
      onWillPop: () async {
        return _onBackKeyAndroid();
      },
      child: CupertinoTabScaffold(
        tabBar: CupertinoTabBar(
          items: [
            BottomNavigationBarItem(
              icon: Icon(Icons.home),
            ),
            BottomNavigationBarItem(
              icon: Icon(Icons.settings),
            ),
            BottomNavigationBarItem(
              icon: Icon(Icons.search),
            ),
          ],
          currentIndex: ConstantsChildContext.selectedIndex,
          onTap: _onItemTapped,
          backgroundColor: Colors.white,
        ),
        tabBuilder: (context, index) {
          return CupertinoTabView(
            builder: (context) {
              return _pageList[index];
            },
          );
        },
      ),
    );
  }
}

constants_child_context.dart
import 'package:flutter/cupertino.dart';

/// androidのバックキー制御するための各タブ分のcontext保持クラス
class ConstantsChildContext {
  static int selectedIndex = 0;
  static BuildContext childContext0;
  static BuildContext childContext1;
  static BuildContext childContext2;
}

/// 初回に各タブのChildContextをセットする
void setChildContext({@required BuildContext childContext}) {
  switch (ConstantsChildContext.selectedIndex) {
    case 0:
      if (ConstantsChildContext.childContext0 == null) {
        ConstantsChildContext.childContext0 = childContext;
      }
      break;
    case 1:
      if (ConstantsChildContext.childContext1 == null) {
        ConstantsChildContext.childContext1 = childContext;
      }
      break;
    case 2:
      if (ConstantsChildContext.childContext2 == null) {
        ConstantsChildContext.childContext2 = childContext;
      }
      break;
  }
}

custom_page.dart
import 'package:flutter/material.dart';

import 'constants_child_context.dart';
import 'full_screen_custom_page.dart';

class CustomPage extends StatelessWidget {
  final Color pannelColor;
  final String title;
  final int pageCount;

  CustomPage(
      {@required this.pannelColor,
      @required this.title,
      @required this.pageCount});

  @override
  Widget build(BuildContext context) {
    setChildContext(childContext: context);
    final titleTextStyle = Theme.of(context).textTheme.title;
    return Scaffold(
      appBar: AppBar(),
      body: Container(
        child: Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Container(
                width: 200,
                height: 200,
                decoration: BoxDecoration(
                    color: pannelColor,
                    borderRadius: BorderRadius.all(Radius.circular(20.0))),
                child: Center(
                  child: Text(
                    title + pageCount.toString(),
                    style: TextStyle(
                      fontSize: titleTextStyle.fontSize,
                      color: titleTextStyle.color,
                    ),
                  ),
                ),
              ),
              TextButton(
                  onPressed: () {
                    Navigator.push(
                      context,
                      MaterialPageRoute(
                        builder: (context) => CustomPage(
                          pannelColor: pannelColor,
                          title: title,
                          pageCount: pageCount + 1,
                        ),
                      ),
                    );
                  },
                  child: Text('下タブあり次画面')),
              TextButton(
                  onPressed: () {
                    Navigator.of(context, rootNavigator: true).push(
                      MaterialPageRoute(
                        builder: (context) => FullScreenCustomPage(
                            pannelColor: pannelColor,
                            title: title,
                            pageCount: pageCount + 1),
                      ),
                    );
                  },
                  child: Text('下タブなし次画面')),
            ],
          ),
        ),
      ),
    );
  }
}

full_screen_custom_page.dart
import 'package:flutter/material.dart';

class FullScreenCustomPage extends StatelessWidget {
  final Color pannelColor;
  final String title;
  final int pageCount;

  FullScreenCustomPage(
      {@required this.pannelColor,
      @required this.title,
      @required this.pageCount});

  @override
  Widget build(BuildContext context) {
    final titleTextStyle = Theme.of(context).textTheme.title;
    return Scaffold(
      appBar: AppBar(),
      body: Container(
        child: Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Container(
                width: 200,
                height: 200,
                decoration: BoxDecoration(
                    color: pannelColor,
                    borderRadius: BorderRadius.all(Radius.circular(20.0))),
                child: Center(
                  child: Text(
                    title + pageCount.toString(),
                    style: TextStyle(
                      fontSize: titleTextStyle.fontSize,
                      color: titleTextStyle.color,
                    ),
                  ),
                ),
              ),
              TextButton(
                  onPressed: () {
                    Navigator.of(context, rootNavigator: true).push(
                      MaterialPageRoute(
                        builder: (context) => FullScreenCustomPage(
                          pannelColor: pannelColor,
                          title: title,
                          pageCount: pageCount + 1,
                        ),
                      ),
                    );
                  },
                  child: Text('次画面へ')),
            ],
          ),
        ),
      ),
    );
  }
}

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

Android11でキーボードの表示/非表示を検知する

Android11で新たに導入されたAPIの中にキーボードの表示/非表示を検知するAPIが追加されたという情報を目にし、試してみました。

調べていく中でいろいろと仕組みをしっかり理解したほうがよさそうな雰囲気を感じたのですが今回はHowに焦点を絞ってまとめます。

実装

実装はいたって簡単です。

1.まず WindowsInset のコールバックを有効にするための設定を行います

Fragment
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        requireActivity().window.setDecorFitsSystemWindows(false)
    }

setDecorFitsSystemWindowsのAPIドキュメント

2.続いて WindowInset のアニメーションのコールバックをViewに設定します

どのViewに設定しても良いようなのでここではrootのviewに設定します

Fragment
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        view.setWindowInsetsAnimationCallback(object: WindowInsetsAnimation.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
            override fun onProgress(
                insets: WindowInsets,
                runningAnimations: MutableList<WindowInsetsAnimation>
            ): WindowInsets {
                return insets
            }
        })
    }

WindowInsetsAnimation.CallbackのAPIドキュメント

上記は必要最低限の実装になりますがキーボードの表示を検知するには onProgress は向いていません。代わりに

3. onEnd をオーバーライドしてキーボードの表示/非表示アニメーション終了を検知します

WindowInsetsAnimation.Callback
    override fun onEnd(animation: WindowInsetsAnimation) {
        super.onEnd(animation)
        if (animation.typeMask == WindowInsets.Type.ime()) {
            val keyboardVisibility =
                view.rootWindowInsets.isVisible(WindowInsets.Type.ime())
            val message = if (keyboardVisibility) "キーボード表示" else "キーボード非表示"
            Toast.makeText(context, message, LENGTH_SHORT).show()
        }
    }

上記コードの結果は以下の通り

コード解説

上記実装の中の3つめの onEnd に関しては前提知識がないとなぜこんなコードになっているかわかりにくいと思うのでちょっとだけ解説します。
今回利用した WindowInsetsAnimation.Callback はおそらくキーボードの動き以外も検知するものだと思われます(ここはしっかり調査したわけではないのですがWindowInsets.Typeがime以外にもいろいろあったためそう推測しました)
なのでanimation.typeMask==WindowInsets.Type.ime() を行いアニメーションの対象がキーボードかどうかを判定してからキーボードの表示状態をチェックしているという形になっています。

所感

Android11以上でしか利用できないのでまだまだこの機能に頼ることはできませんが、今までの「Viewの変化を自分で検知してキーボード表示/非表示の判定をする」に比べると格段にわかりやすくなったと思います。
ただWindowInsetsについてはもっと理解をしなければとも感じたのでこれからも学習を続けたいと思います。

参考・関連記事

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

【Android】SpannableStringを使って文字列に画像を挿入する

はじめに

この記事はand factory Advent Calendar 2020 の12日目の記事です。
昨日は@k_shinnさんの 【Android】ちょっと便利なDrawableを書く でした。

やりたいこと

  • 画像を文字列として扱いたい

背景

Androidアプリを作っていく中で「テキストの横に画像を表示したいんだけど〜」
という旨のオーダーをもらうことがありまして
「そんなの簡単です!」と素直〜にImageViewの横にTextViewを置いて…と下記xmlで実装しました。

hoge.xml
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginStart="20dp"
        android:layout_marginEnd="20dp"
        android:layout_marginBottom="20dp"
        android:background="@color/background">

        <ImageView
            android:id="@+id/icon"
            android:layout_width="20dp"
            android:layout_height="20dp"
            android:src="@mipmap/ic_launcher"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/repository_name"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:ellipsize="end"
            android:textSize="20sp"
            android:textStyle="bold"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toEndOf="@+id/icon"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="テキストテキストテキスト" />
    </androidx.constraintlayout.widget.ConstraintLayout>

できた!
スクリーンショット 2020-12-11 23.58.36.png

ですが長い文字列が来た際に困ったことが起きました。
そうです、文字の折り返し地点が不自然なものとなってしまうのです…!

スクリーンショット 2020-12-12 0.02.34.png

今回求められていた要件としては
文字列の2行目の位置がアイコンの真下に来るイメージのものが求められていました。

★hogehoge
hogehoge

そのため、SpannableStringでちょっとゴリった実装をしていくことにしました。

ザックリ言ってしまうと画像を挿入する分の文字スペースを作って
そのスペースにSpannableStringで画像をねじ込んでいくスタイルです。

コード

IconSpannable.kt
class IconSpannable(private val targetTextView: TextView) {

    fun insertIconPrefix(@DrawableRes prefixDrawableRes: Int, sourceString: String): SpannableString {
        // 画像を挿入するため、一文字分の空白を作る
        val resultString = SpannableString("  $sourceString")
        return insertImage(prefixDrawableRes, resultString, 0, 1)
    }

    private fun insertImage(
        @DrawableRes drawableRes: Int,
        resultString: SpannableString,
        startSpan: Int,
        endSpan: Int
    ): SpannableString {
        val context = targetTextView.context
        // 対象のTextViewの高さを計測
        val size = (targetTextView.paint.descent() - targetTextView.paint.ascent()).toInt()
        val drawable = context.resources.getDrawable(drawableRes, null)
        drawable?.let {
            it.setBounds(0, 0, size, size)
            val span = ImageSpan(it)
            resultString.setSpan(span, startSpan, endSpan, Spanned.SPAN_EXCLUSIVE_INCLUSIVE)
        }
        return resultString
    }
}

使い方

    binding.repositoryName.text =
        IconSpannable(binding.repositoryName).insertIconPrefix(
            R.mipmap.ic_launcher,
            "テキストテキストテキストテキストテキストテキスト"
        )

結果

無事にTextViewの2行目が画像の下にでてきてくれました:v:
Screenshot_20201212-012349.png

終わりに

以上、SpannableStringを使って文字列に画像を挿入する方法でした!
単純そうな要件であっても意外とめんどくさいのがAndroid、というよりエンジニアの常ですよね。
どなたかの助けになれば幸いです。

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

AndroidTestをFastlaneで実行する

Androidアプリのインストゥルメント化単体テスト(ハードウェア デバイスまたはエミュレータで実行されるUIを使用したテスト)をCI環境に組み込むためにFastlaneから実行する方法のメモ。

背景

AndroidStudioからの実行はandroidTestディレクトリのコンテキストメニューから実行するという非常に直感的でわかりやすいが、完全に自動化しようとすると下記のステップを自動化する必要があり、自前では意外と簡単には行かなかった。

  • エミュレータを起動
  • 起動完了を待つ
  • テストを実行
  • テスト終了を待つ
  • エミュレータを終了

前提

  • Androidアプリ開発に必要なツールは一通りインストール済み
  • Fastlaneを既にプロジェクトで使用している

TL;TR

AzimoLabs/fastlane-plugin-automated-test-emulator-run Fastlaneプラグインを使用することで、設定のみで実現できてしまう。

手順

プラグインのインストール

fastlane add_plugin automated_test_emulator_run

プロジェクトのfastlaneディレクトリにAVD_setup.jsonを作成する

{
  "avd_list":
  [
    {
      "avd_name": "Nexus5_API_21_Play",

      "create_avd_package" : "system-images;android-21;google_apis;x86_64",
      "create_avd_device" : "Nexus 5",
      "create_avd_tag" : "google_apis",
      "create_avd_abi": "x86_64",
      "create_avd_hardware_config_filepath": "",
      "create_avd_additional_options": "",

      "launch_avd_snapshot_filepath": "",
      "launch_avd_launch_binary_name": "emulator",
      "launch_avd_port": "",
      "launch_avd_additional_options": "-gpu on"
    }
  ]
}

create_avd_device ではハードウェアプロファイルを指定する。AVD Manager GUI版では下記スクショの画面で選択するものにあたる。
Screen Shot 2020-12-12 at 0.00.52 2.png

avdmanager list device コマンドで表示されるリストのIDを指定する。

% ~/Library/Android/sdk/tools/bin/avdmanager list device
...
---------
id: 14 or "Nexus 9"
    Name: Nexus 9
    OEM : Google
---------
id: 15 or "Nexus One"
    Name: Nexus One
    OEM : Google
---------
id: 16 or "Nexus S"
    Name: Nexus S
    OEM : Google
---------
id: 17 or "pixel"
    Name: Pixel
    OEM : Google
---------
id: 18 or "pixel_c"
    Name: Pixel C
    OEM : Google
---------
id: 19 or "pixel_xl"
    Name: Pixel XL
    OEM : Google

で表示されるリストのIDを指定する。
リスト中の
id: 8 or "Nexus 5"
のところ。

その他の詳細はこちら
https://github.com/AzimoLabs/fastlane-plugin-automated-test-emulator-run#json-config

fastlane/Fastfileに下記の様にlaneを定義。

lane :run_android_test do
  # Delete existing emulator data to avoid failure of starting emulator
  sh("rm -rf ~/.android/avd/Nexus5_API_21_Play.avd")
  automated_test_emulator_run(
    AVD_setup_path: "fastlane/AVD_setup.json",
    gradle_task: "connectedDevelopmentDebugAndroidTest",
    gradle_flags: "-Pandroid.testInstrumentationRunnerArguments.class=com.smbc_card.vpoint.ui.email.EmailActivityTest",
  )
end

AVD_setup_pathに上記で作成したjsonファイルを指定する。

gradle_taskで指定するタスクはconnectedVariantNameAndroidTestとなる。つまり、フレーバーがDevelopmentでビルドモードがDebugなら connectedDevelopmentDebugAndroidTestとなる。
公式ドキュメントはこちら

gradle_flagsは必須ではないが、例では実行するテストクラスを指定している。
ここからの情報

sh("rm -rf ...") は前回作成されたavdのディレクトリが残っているとエラーになることがあったのだが、原因までは調べる時間なかったので、毎回削除することで回避している。

下記コマンドで実行

bundle exec fastlane run_android_test

Happy testing~!

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