Iganinのブログ

日頃の開発で学んだ知見を中心に記事を書いています。

【SwiftUI】BackButtonの文字を消す

語り尽くされている感があるが、今一度調べたので備忘録的にメモ。

tl;dr;

  • UINavigationBarのAppearnceをいじることで達成可能
  • navigationBarBackButtonHiddenにして、navigationItemをnavigationBarLeadingに追加でも対応できるが以下の機能が消える
    • SwipeGestureによる元の画面への遷移
    • 戻るボタン長押しによるStackの表示と選択による画面遷移

環境

以下の環境で確認

UIKit

まずUIKitの場合にどうするかを記述する。 これは非常に簡単に行うことができて、navigationItem.backButtonDisplayModeminimalにしてやれば良い。 iOS 14以上の制約はあるが、昨今は基本的にはこのサポートバージョンの条件を満たしていると思う。 backButtonDisplayMode | Apple Developer Documentation

SwiftUI

SwiftUIでどうするかを記述する。

UINavigationBarのAppearanceをいじる

おそらくこの方法が筋が良いと思う。 以下のようにすることで、BackButtonのテキストを非表示にすることができる。

appearance.backButtonAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.clear]

この方法であれば、後述の方法では無効化されてしまうデフォルトの以下の機能も無効化されない。

  • Swipe Gestureによる画面遷移
  • 長時間押下によるStackの表示 + 指定した画面への遷移

ToolbarItem(placement: .navigationBarLeading)

navigationBarBackButtonHidden()でBackButtonを非表示にして、ToolbarItem(placement: .navigationBarLeading) で独自の戻るボタンを置く方法。 以下のようにViewModifierを作成すると設定がだいぶ楽になる。

struct MinimalNavigationBarBackButton: ViewModifier {
  @Environment(\.dismiss) var dismiss

  public func body(content: Content) -> some View {
    content
      .navigationBarBackButtonHidden(true)
      .toolbar {
        ToolbarItem(placement: .navigationBarLeading) {
            Button {
                dismiss()
            } label: {
                Image(systemName: "chevron.backward")
            }
            .foregroundStyle(Color.black)
        }
      }
  }
}

直感的な方法ではあるが、デフォルトである以下の機能が無効になる。

  • A Swipeによる元の画面への画面遷移
  • B 戻るボタンの長時間押下によるStackの表示 + 指定Stackへの遷移

AについてはUINavigationControllerをいじることで実装できるが、全体に適用するとBackButtonがHiddenでもSwipeで戻れてしまうので制御が必要。 Bもおそらく実装できるが手間はかかりそう。

extension UINavigationController: UIGestureRecognizerDelegate {
    override open func viewDidLoad() {
        super.viewDidLoad()
        interactivePopGestureRecognizer?.delegate = self
    }

    public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        return viewControllers.count > 1
    }
}

その他

以下の方法も検討できるが、BackButton以外の挙動が変わりそうなのでその辺りの考慮が必要そう。

  • 画面遷移と同時にUserのnavigationTitleを空文字にする
  • toolbarRoleにeditorを指定

Reference

2024年の目標

はじめに

新年明けましておめでとうございます。 今年もよろしくお願いいたします。 しばらくブログの更新ができていませんでしたが、年も明け心機一転頑張っていこうと思います。

2023年の振り返り

2023年の定めた目標と最終進捗は以下です。 想定していた進捗は出せていませんが、目標外のプライベートで一定の進捗があったりなど充実してはいました。

技術

  • Point Free 動画全視聴 -> 30%くらい
  • HIG 通読 -> 30%
  • Swift by Sundell discovery 通読 -> 50%
  • Coursera MLコース 2 50%

  • ベンチプレス 130kg -> 90kg
  • スクワット 120kg -> 130kg
  • デッドリフト 120 kg -> 110kg

2024年の目標

技術

iOS

MUST
  • WWDC 2023-2024 動画全視聴
  • Point Free 動画全視聴
PREFERABLE
  • HIG 通読

CS

PREFERABLE

技術広報

MUST
  • 技術ブログ 12 / year
PREFERABLE
  • カンファレンス 登壇 1

知識

MUST
  • 技術書通読 12冊
PREFERABLE
  • 一般教養書 12冊
  • 小説 12冊

MUST

進捗管理

昨年は定めた目標に対する進捗管理が甘かったので、以下の管理を実施していきます。

  • 週次の目標確認と進捗更新

終わりに

1日24時間では足りないなと感じる割合が増えてきました。 やるべきこともですが、やらないことも明確に定めるとともに、一日の活動時間を増やせるよう体力のより一層の向上が不可欠なように感じています。 ずっとやろうと思っていた格闘技や楽器演奏も始めたいですが、どこまでできるか...。NISAの360万円枠も埋められるようにしたい...。 来年の同時期に良い一年を過ごせたと振り返られるよう無理のない範囲で頑張ります。

【SwiftUI】要素数の少ない場合の表示も考慮したチャットライクな画面の実装

環境

以下の環境で実施しています。

要件定義

いわゆるチャットのUIにおいては、最新の要素が画面下部に追加されていき、画面の初期表示時は画面最下部を基準に表示されているという要件が主流かと思います。

また、その一方で要素数が少なく画面全体を占めないような場合は要素が上の方に詰まっている以下のような画面を表示するのが要件として一般的ではないでしょうか。

つまり、以下の要件が一般的なチャット画面に期待されるものかと思います。

  • 要件① 最新のデータは画面下部に追加されていき、画面上部が過去のデータとなる。
  • 要件② 要素数が少ない時は画面上部に要素が詰まっている。

要件①を満たす実装

要件①を満たすための実装を考えます。 ScrollViewやListをそのまま使用すると、scrollや画面表示の都合上実現が難しいため工夫が必要です。 さまざまな実現方法があるかと思いますが、ScrollView、Listを反転し、中身の要素を再度反転させ整合性をとる実装が多いのではないでしょうか。

struct SampleScreen: View {
    @State private var list: [Sample] = []
    
    var body: some View {
        List {
            Group {
                ForEach(list) { sample in
                    cell(sample: sample)
                }
            }
            .rotation3DEffect(.degrees(180), axis: (x: 1, y: 0, z: 0))
        }
        .rotation3DEffect(.degrees(180), axis: (x: 1, y: 0, z: 0))
    }
}

問題

先ほどの実装で要件①は満たすことができますが、実は要件②は満たせていません。 Listを180度回転させている影響で、要素が画面下部によってしまい、要素数が少ない場合に要素が下に寄ってしまいます。

解消方法

Viewを反転させ要件①を満たす方法のみでは要件②が満たせないことをお伝えしました。 ここで少々トリッキーではありますが、最近見つけた方法を共有します。

考え方は以下です。

  1. Scroll領域の下部をSpacerなどで埋める
  2. 1を実現するために、Scroll領域のContentsの高さを測り反映させる

具体的な実装を見ていきましょう。

解消方法の実装

具体的な実装は下記のようになります。 ScrollViewの内部に内部要素を上に詰めるためのSpacerを含めています。 Spacerの高さとScrollViewに含まれる他の要素の高さの合計が画面全体の高さを越えるようにする必要があります。

ここで他の要素の高さを取得するのがそのままでは難しいため、PreferenceKeyと@Stateで定義したheightを使用しSpacerに伝えるようにしています。 こちらの実装で先ほどの要件②も満たすことができ、要件①、要件②を満たすチャットライクな画面を実現できます。

struct HeightKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue: CGFloat = 0

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value += nextValue()
    }
}

struct SampleView: View {
    @State private var list: [Sample] = []
    @State var height: CGFloat = 0

    var body: some View {
        GeometryReader { scrollViewProxy in
            ScrollView {
                // 要素数が少ない場合に要素を上詰めするために画面下部にスペースを確保する
                // 他の要素で画面全体のスペースが埋まる場合は不要なので、高さを0とする
                Spacer()
                    .frame(height: max(scrollViewProxy.size.height - height, 0))
                LazyVStack(alignment: .leading, spacing: 0) {
                    ForEach(viewModel.list) { sample in
                        cell(sample: sample)
                            .rotation3DEffect(.degrees(180), axis: (x: 1, y: 0, z: 0))
                    }
                }
                .overlay {
                    GeometryReader { contentsProxy in
                        // PreferenceKeyを使用しコンテンツの高さを伝える
                        Color.clear.preference(
                            key: HeightKey.self,
                            value: contentsProxy.size.height
                        )
                    }
                }
            }
            .rotation3DEffect(.degrees(180), axis: (x: 1, y: 0, z: 0))
            .onPreferenceChange(HeightKey.self) { newHeight in
                // コンテンツの高さの変化を検知し、\@Stateで保持している
                // heightに伝えViewに反映させる
                height = newHeight
            }
        }
    }
}

終わりに

今回ご紹介した実装を書きGistにアップロードしていますので、ご興味ある方はお手元で動かしてみてください。

https://gist.github.com/HironobuIga/1abd6e38fddc5d06f1e7be5a49208704

@tobi462さん@_natpenguinさんのお力添えのおかげで今回の実装にたどり着くことができました。 この場を借りまして御礼申し上げます。

【iOS】ARKitのFace Trackingの結果をobjファイルとして出力する

TL;DR;

  • ARSessionでARFaceTrackingConfigurationを走らせる
  • ARFaceGeometryをMDLAssetに変換する
  • MDLAssetのexport(to:)メソッドを使う

objファイルについて

いわゆるobj形式のファイル。下記の記事が参考になった。 ブログ記事からの引用ではあるが、objファイルは頂点座標,頂点法線,頂点テクスチャ座標から構成されるポリゴンを記述できる. 頂点座標のみや座標と法線のみなどでの記述も可能である.とのこと。 OBJ形式 - PukiWiki for PBCG Lab

objファイルをXcodeで閲覧すると下記のようになる。 こちらは自身の顔をARKitのFace Trackingしたデータをobjファイルに変換して出力したもの、本稿ではARKitでの検知結果を下記のようなobjファイルとして出力することを目指す。

f:id:Iganin:20211213044526p:plain

ARKit Face Tracking Configuration

ARKitのFace Trackingに関して簡単に記す。 ARSessionを生成し、runする際にARFaceTrackingConfigurationを指定することで、機能を使用できる。 ( Tracking and Visualizing Faces) arSession.run(ARFaceTrackingConfiguration())

ARSCNViewを使用することで、ARSCNViewDelegateのメソッドの renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor)を経由してFace Trackingの情報を取得できる。

if let faceAnchor = anchor as? ARFaceAnchor {
  // do something with ARFaceAnchor
}

ARFaceAnchorからは目の位置、顔が向いている方向などさまざまな情報を取得できる(ARFaceAnchor)が、本稿では geometry: ARFaceGeometryを使用する。 ARFaceGeometryは顔の形状を三角形のメッシュで近似的に検出した際の各頂点やテクスチャの座標系を提供してくれる。(ARFaceGeometry) ARFaceGeometryを使用すれば顔の形状を表示することができる、簡単なところだとARSCNFaceGeometryを使用してARSCNViewに描画することができる。

    func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
        guard let device = renderer.device else { return nil }
        let node = SCNNode(geometry: ARSCNFaceGeometry(device: device))
        return node
    }

以下ではこのデータをobjファイルに変換する方法を考える。

ARKit の Face Geometryデータをobjファイルに変換する

Face Geometryをobjファイルに変換するにあたって、Face GeometryをMDLAssetに変換する。(MDLAsset) MDLAssetの説明を読むと、export(to:)メソッドを使用することで、MDLAssetの内容をファイルに出力することができる。(export(to:)) 以下、Face GeometryをMDLAssetに変換する方法を記載する。

Face GeometryをMDLAssetに変換するにあたって、大まかに下記の手順を取る。

  • MDLMeshBufferDataAllocatorの作成
  • MDLSubmeshの作成
  • MDLMeshの作成
  • MDLAssetの作成
  • MDLMeshをMDLAssetに含める
  • MDLAssetからobjファイルを出力する

MDLMeshBufferDataAllocatorの作成

MDLAssetの初期化にはよくURLを指定してリソースを読み込む方法が使われるが、今回はbufferAllocatorを使用する。(init(bufferAllocator:)) この方法はドキュメントにもある通り、プログラムでMDLAssetを作成するのに使用される。 MDLMeshBufferAllocatorの生成部分の実装は下記のようになる。

let allocator = MDLMeshBufferDataAllocator()

以下ではindexやvertex、coordinateの情報をallocatorを使用しbufferに変換して使用していく。

MDLSubmeshの作成

次にMDLSubmeshを作成する。MDLSubmeshの作成のためにはindexBufferが必要になる。今回の顔のMeshに関してはARFaceGeometryがtriangleIndicesを持っているためそれを使う。 初期化の実際の実装は下記のようになる。

let triangleIndicesBuffer = allocator.newBuffer(
    with: Data(bytes: triangleIndices, count: triangleIndices.count * MemoryLayout<Int16>.stride),
    type: .index
)

let subMesh = MDLSubmesh(
    indexBuffer: triangleIndicesBuffer,
    indexCount: triangleIndices.count,
    indexType: .uInt16,
    geometryType: .triangles,
    material: nil
)

MDLMeshの作成

MDLMeshを作成する。Mesh作成にあたって必要なindicesは作成しsubmeshに渡したので、ここでは各頂点と各頂点に対応したcoordinateを渡す。 各頂点のverticesBufferとcoordinateBufferの実装は下記のようになる。Data生成時に使用するデータ容量を計算する際にMemoryLayout.strideとし各データ型のメモリ容量を考慮することに注意する。 coordinateは直感的には2 vector必要じゃないかという気もするが、法線が決まれば平面の座標系も決まるはずなので、多分法線ベクトルを意味していると思われる(関連ドキュメントはまだしっかりと読めていない...) vertexはSIMD3なのでとても素直に理解しやすいと思う。

let verticesBuffer = allocator.newBuffer(
    with: Data(bytes: vertices, count: vertices.count * MemoryLayout<SIMD3<Float>>.stride),
    type: .vertex
)

let coordinatesBuffer = allocator.newBuffer(
    with: Data(bytes: textureCoordinates, count: textureCoordinates.count * MemoryLayout<SIMD2<Float>>.stride),
    type: .vertex
)

MDLMeshを作成するには、descriptorを指定する必要がある。(init(vertexBuffers:vertexCount:descriptor:submeshes:)) MDLVertexDesciptorは下記のように作成する。今回はデータとしてvertexとcooridinateを使用しているため、それらの設定を行なっている。 なぜこの設定が必要かについては以下のStackOverflowの回答が詳しい。(Save ARFaceGeometry to OBJ file)

let vertexDescriptor = MDLVertexDescriptor()
vertexDescriptor.attributes[0] = MDLVertexAttribute(
    name: MDLVertexAttributePosition,
    format: .float3,
    offset: 0,
    bufferIndex: 0
)
vertexDescriptor.attributes[1] = MDLVertexAttribute(
    name: MDLVertexAttributeTextureCoordinate,
    format: .float2,
    offset: 0,
    bufferIndex: 1
)
vertexDescriptor.layouts[0] = MDLVertexBufferLayout(
    stride: MemoryLayout<SIMD3<Float>>.stride
)
vertexDescriptor.layouts[1] = MDLVertexBufferLayout(
    stride: MemoryLayout<SIMD2<Float>>.stride
)

上記を使用してMDLMeshを生成する。

let mdlMesh = MDLMesh(
    vertexBuffers: [verticesBuffer, textureCoordinatesBuffer],
    vertexCount: vertices.count,
    descriptor: vertexDescriptor(),
    submeshes: [subMesh]
)
mdlMesh.addNormals(withAttributeNamed: MDLVertexAttributeNormal, creaseThreshold: 0.5)

MDLMeshのaddNormals()を呼び出すことで、実際のデータの生成を行う。

MDLAssetの作成, MDLMeshをMDLAssetに含める, MDLAssetからobjファイルを出力する

MDLAssetを作成し、MDLMeshをMDLAssetに含める。

let asset = MDLAsset(bufferAllocator: allocator)
asset.add(mdlMesh)

最後に作成したMDLAssetをexportする。この際に指定する引数のURLはMDLAssetのドキュメントに記載されている通りファイルURLでなければならないことに注意する。

let saveFile = FileManager.default.createDocFile(ext: "obj")
try asset.export(to: saveFile)

参考

【Kotlin】BCryptを使用してPasswordのハッシュ化を行う

TL;DR;

パスワード暗号化

前提としてパスワードの保存について。 平文保存はまずめちゃくちゃまずいと思う、ネットワークに侵入されてDB見られたら終わってしまう。 暗号化で十分かというと実はそうでもない。内部に悪意のある管理者がいた場合DBにアクセスしてパスワードを復号するということもあり得る。 パスワードはシステムの最後の砦として、ユーザー本人以外が知り得ない状態にするのが望ましい。

そのために多くの場合Hash化を行うと思う。Hash化を行うことでパスワードの入力値 -> ハッシュ化パスワードの変換はでき、 ハッシュ化パスワードとDBに保存されたハッシュ化パスワードを比較し一致しているかどうか判断することができる。 しかし、ハッシュ化パスワードから元のパスワードを導き出すことはできないため、内部の悪意のある管理者によるパスワード取得を防ぐことができ、より堅牢にできる。 ただし、簡単なHash化関数だと元に戻すことができてしまうらしいし、Hash化の回数が少ないと脆弱性が増すということにも注意が必要らしい。

また、上記を行った上でもパスワードに対して辞書攻撃やレインボーテーブル攻撃をされてしまう可能性もある。 Saltをユーザーごとに設定し、パスワードにSaltを付与することでこの攻撃を防ぐことができる。

以上より、パスワード保存に当たっては以下が必要だと思う。

  • 暗号化
  • 適切なハッシュ化
  • Saltの付与

BCrypt

Blowfish暗号化を実装したライブラリらしい。 多くの記事で推奨されているのと、社内のセキュリティに詳しいエンジニアがBlowfish暗号化を推奨していたので、 パスワードの暗号化ではBCryptを使用すれば恐らく大きな間違いはないと思う。

使い方は至って簡単。必須なメソッドは下記のようになる。

// saltを生成する。引数でHash化の回数が決まるらしい。 2の引数乗実行される。数を増やすと指数的に処理時間が増大するため注意
String gensalt(int log_rounds)

// 平文のパスワードとsaltからハッシュ化されたパスワードを生成する
String hashpw(String password, String salt)

// 平文のパスワードとハッシュかれたパスワードを比較し一致しているかどうかを判定する
// 引数にsaltが不要である点に注意
String checkpw(String plaintext, String hashed)

最後に

セキュリティ周りのことを書くのはドキドキします。 間違っている点があったらご指摘ください。

【Kotlin】ExposedでDateTimeを扱う

TL;DR;

  • org.jetbrains.exposed:exposed-java-timeを使う
  • timezone付きでの保存には対応してないため注意
  • localDate, localDateTimeへの変換の際にsystemDefault TimeZoneが使用されるため注意

環境

  • Exposed 0.23.1

内容

ExposedはKtorとともに主に使用されるKotlin製のORMである。 TransactionalなデータのテーブルにはデータのCRUD処理に伴うcreateされた時刻や、updateされた時刻などを基本的には保存しておきたい。 Exposedで該当の処理を行おうとした場合、そのままではできず、 exposed-java-time を使用する必要がある。

exposed-java-timeをbuild.gradleのdependenciesに追加し、Daoで使用することができる。

dependencies {
    ~~~
    // build.gradleのdependenciesに下記を追加
    implementation "org.jetbrains.exposed:exposed-java-time:$exposed_version"
    ~~~
}
import org.jetbrains.exposed.dao.id.LongIdTable
import org.jetbrains.exposed.sql.`java-time`.date
import org.jetbrains.exposed.sql.`java-time`.datetime

object SampleDao : LongIdTable("samples") {
  val name = varchar("name", 255)
  val date = date("date")
  val createdAt = datetime("created_at")
  val updatedAt = datetime("update_at")
}

注意点は2つ。

DBのtimestamp with timezoneをサポートしていない。また、OffsetDateTimeやZonedDateTimeのサポートはない。 DBにtimezone付きの時刻を保存できない以上、コード側でtimezoneをUTCに変換しDBとやり取りするしかないと思う。

DBのColumnからlocalDateやlocalDateTimeを生成する際にTimeZoneとしてsystemDefaultが使用される。 そのため、DockerなどでTimeZoneを指定している場合は注意する必要がある。

参考

【Flutter】RiverpodのProviderの変化を検知する

TL;DR;

  • ProviderObserverを使用し,ProviderScopeのobservers引数に設定する

環境

[] Flutter (Channel stable, 2.2.3, on macOS 11.3.1 20E241 darwin-x64, locale ja-JP)
[] Xcode - develop for iOS and macOS
[] Chrome - develop for the web
[] Android Studio (version 2020.3)
[] IntelliJ IDEA Ultimate Edition (version 2021.1.2)
[] VS Code (version 1.59.1)

hooks_riverpod: 0.14.0+4

ProviderObserver

デバッグ時などにProviderの生成や破棄、値の状態の変化を確認したいことがある。 変なタイミングでDisposeされていないか、逆に適切なタイミングでDisposeされずに残り続けていないかを把握することは意味がある。

Riverpodにはそういった要望に応える仕組みとして、ProviderObserverがある。 ProviderObserverはProviderContainerの状態を監視し、変化をメソッド経由で受け取る。 メソッドは現状4つある。

// providerの追加時に呼ばれる
void didAddProvider(ProviderBase provider, Object? value)
// providerの依存関係変化時に呼ばれる
void mayHaveChanged(ProviderBase provider)
// providerからnotificationが発されたタイミングで呼ばれる
// 依存関係が変化したとしても、valueが変化していない場合は呼ばれない
void didUpdateProvider(ProviderBase provider, Object? newValue)
// providerが破棄されたタイミングで呼ばれる
void didDisposeProvider(ProviderBase provider)

実装

実際の実装は以下のようになる。 ProviderObserverを継承したクラスを作成し、メソッドをoverrideする。 そのクラスをProviderScopeのobservers引数に渡す。

あとは、ProviderObserverを継承したクラスでoverrideしたメソッド内でログメソッドなど好きなメソッドを呼び出せばいい。 なお、各メソッドはデフォルトの処理が定義されているので、必要なメソッドのみoverrideすれば良い。

@immutable
class _ProviderObserver extends ProviderObserver {
  const _ProviderObserver();

  @override
  void didAddProvider(ProviderBase provider, Object? value) {  log method }

  @override
  void didDisposeProvider(ProviderBase provider) { log method   }

  @override
  void didUpdateProvider(ProviderBase provider, Object? newValue) { log method }

  @override
  void mayHaveChanged(ProviderBase provider) { log method  }
}

void main() {
  runApp(
    ProviderScope(
      observers: [
        const _ProviderObserver(),
      ],
      child: App(),
    ),
  );
}

参考

【Flutter】FlutterのDartコードの難読化について

TL;DR;

  • Dartコードの難読化には buildOptionとして --obfuscate --split-debug-info をつける

難読化について

難読化はクラス名やメソッド名を変更し、バイナリサイズを落とすことを主な目的としているらしい。 例えば、 SpecialCatクラスをaに置き換えるといったことを行う。

難読化の効果として、リバースエンジニアリングをしずらくするということが上げられることもあるが、 AndroidのR8による難読化の説明のページにはその点に関する言及はなく、実際はそこまでの効果はないのかもしれない。(とはいえクラス名やメソッド名が生で出力されているよりはしずらくはなる...はず?)

Flutterでの難読化について

Flutterでの難読化においては、各OSのコードの難読化に加えて、 Dartコード部分の難読化を行う必要がある。 難読化の仕組みに関して、Flutter 1.16.2以前は下記の記事に記載のあるように少々手間がかかった。

github.com

Flutter 1.16.2以降は非常に簡単に難読化ができるようになっており、ビルド時のコマンドに下記の引数を付与することで難読化が可能となっている。

  • --obfuscate
    • このオプションがあることで難読化を行うことを示す。
  • --split-debug-info
    • symbolファイルの出力先を指定する。難読化を戻すために使用する。
  • 注: 上記のオプションは2つ同時に使用する必要がある(片方のみの場合エラー)
// 例
// --split-debug-infoには任意のフォルダを指定可能
flutter build apk --obfuscate --split-debug-info=obfuscate/android
flutter build ios  --obfuscate --split-debug-info=obfuscate/ios

下記の公式ドキュメントに詳しい記載あり。 flutter.dev

注意点

FlutterでのCrash検知ツールとしてFirebase Crashlyticsを使用することが多いかと思うが、難読化の解読に関しては以前のバージョンではサポートされていなかった。以下のPRで対応が実施されたようである。

fix(firebase_crashlytics): Include obfuscated stack traces by untp · Pull Request #4407 · FirebaseExtended/flutterfire · GitHub

ChangelogをみるとCrashlytics 2.0.1で対応が入ったようなので、難読化する場合は2.0.1以降のバージョンを使用するよう気をつける必要がある。

firebase_crashlytics | Flutter Package

参考

【Flutter】ScrollViewとExpandedを併用してSignIn / SignUp画面 などのレイアウトを作成する方法

TL;DR;

  • LayoutBuiderとIntrinsicHeightを併用する

環境

[] Flutter (Channel stable, 2.2.3, on macOS 11.3.1 20E241 darwin-x64, locale ja-JP)
[] Xcode - develop for iOS and macOS
[] Chrome - develop for the web
[] Android Studio (version 2020.3)
[] IntelliJ IDEA Ultimate Edition (version 2021.1.2)
[] VS Code (version 1.59.1)

モチベーション

添付の画像のようなUIを考える。サインイン画面やサインアップ画面によくある、ロゴ、TextField、ボタンという構成。 画面によってはボタンを画面下部に置くことがあると思う。 サインアップページでは複数項目を入力する必要があることも多く、複数画面に渡って画面下部にボタンを置くという構成にしたいというケースがある。

f:id:Iganin:20210904145721p:plain
ボタンを画面下部に固定したデザイン

画面に十分なスペースがある場合は良いが、TextFieldが複数個配置されたり、画面に画像などを複数置き十分なスペースが確保できなくなると問題に当たる。 通常であればTextFieldにフォーカスが当たりキーボードが表示されると画面の構成要素がキーボード分上部に移動する。 スペースが十分にある場合はこれで良く、多くの場合問題にならない。

スペースが十分に取れていないとこの方法だと画面レイアウトが崩れエラーとなる。 このような崩れを回避するための方法として以下の二つがある。

f:id:Iganin:20210904145739p:plain
余白が十分にあれば問題はない
f:id:Iganin:20210904145817p:plain
余白が不足するとレイアウトエラーとなる

  • (1) Scaffoldの resizeToAvoidBottomInsetをfalseにする
  • (2) SingleChildScrollViewでWrapする

(1)の方法はキーボードの表示によりTextFieldが隠れてしまい入力内容が参照できなくなりUXを損なうという問題が発生する。 多くの場合は(2)の方法で問題を回避するが、本稿の前提となるUIの場合問題が発生する。 本稿のUIのようにボタンを画面下部に固定して表示しようとした場合、余白部分をSpacer()などを使って埋めることが多いと思う。 ただSingleChildScrollViewは内部から高さを決めようとするがSpacerやExpandedでは可能な限りスペースを広げようとするため、 高さが不定となり、画面レイアウトが崩れる。

The following assertion was thrown during paint():
RenderBox was not laid out: RenderRepaintBoundary#f85cc relayoutBoundary=up1 NEEDS-PAINT
'package:flutter/src/rendering/box.dart':
Failed assertion: line 1930 pos 12: 'hasSize'

f:id:Iganin:20210904145851p:plain
TextFieldが隠れてしまい入力値が見えない

ではどのようにすれば、キーボードの表示問題を解決しつつ希望するUIを実現できるのだろうか。

解決策

実はSingleChildScrollViewの公式ドキュメントに解決方法の記載がある。 下記のページの Expanding content to fit the viewportを参照されたい。 api.flutter.dev

もしくは下記のFlutterのIssueもヒントになると思う。

github.com

以下に対応した場合のコードを記載する。

LayoutBuilderによって、親Widgetの画面サイズを取得し、ConstrainedBoxによってminHeightを親Widgetの高さと同一とする。 こうすることで、内部要素が画面サイズいっぱいまで広がるようになる。 ただ、このままでは先程の高さが不定になるという問題は解消しない。

そこでIntrinsicHeightを使用する。 IntrinsicHeightは高さを内部の構成要素の高さの合計値と同一にする。 こちらで決まる高さの制約はConstrainedBoxのheightの制約より弱いため、 内部にExpandedやSpacerがある場合は画面いっぱいまでExpandedやSpacerが拡張される。 この仕組みによって、目標であるレイアウトは達成される。

注意点としてはドキュメントにも記載があるがIntrinsicHeightによるレイアウトコストが高いことがあげられる。 Column内の構成要素はできるだけ少なくすることが望まれる。

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Sample'),
      ),
      body: LayoutBuilder(
        builder: (context, constraints) => SingleChildScrollView(
          child: ConstrainedBox(
            // この部分で画面全体のサイズを高さの最小値としている
            // したがって、内部の構成要素のheightの合計値が画面サイズに満たなくても
            // 画面サイズいっぱいまで内部要素が拡張される
            constraints: BoxConstraints(minHeight: constraints.maxHeight),
            // IntrinsicHeightが構成の要である
            // IntrinsicHeightによって、高さが内部構成要素の合計値となる
            // この制約はConstrainedBoxによる制約より弱いため、
            // ConstrainedBoxの高さの制約によってSpacerが余白分まで広がる
            child: IntrinsicHeight(
              child: Padding(
                padding: const EdgeInsets.all(20),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.start,
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: <Widget>[
                    Container(
                      color: Colors.amber,
                      height: 250,
                      child: Text(
                        'ロゴ',
                        style: TextStyle(fontSize: 80),
                      ),
                    ),
                    const SizedBox(height: 44),
                    TextField(
                      decoration: InputDecoration(
                        hintText: 'メールアドレス',
                      ),
                    ),
                    const SizedBox(height: 44),
                    TextField(
                      decoration: InputDecoration(
                        hintText: 'パスワード',
                      ),
                    ),
                    const SizedBox(height: 44),
                    const Spacer(),
                    SizedBox(
                        height: 88,
                        child: ElevatedButton(
                            onPressed: () {},
                            child: Text(
                              'ログイン',
                              style: TextStyle(fontSize: 20),
                            ))),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

参考文献

【Flutter】GraphQLClientでキャッシュの永続化を行う(DIにRiverpodを使用)

TL;DR;

  • HiveStoreを使う
  • HiveStoreの初期化は非同期処理のためgraphqlのproviderがFutureとなってしまいめんどくさい
  • 上記解消のために main.dart で 初期化を終わらせ ProviderScopeでoverrideする

環境

[] Flutter (Channel stable, 2.2.3, on macOS 11.3.1 20E241 darwin-x64, locale ja-JP)
[] Xcode - develop for iOS and macOS
[] Chrome - develop for the web
[] Android Studio (version 2020.3)
[] IntelliJ IDEA Ultimate Edition (version 2021.1.2)
[] VS Code (version 1.59.1)

hooks_riverpod: 0.14.0+4
graphql: 5.0.0

Cash Persistence

GraphqlClientはクライアント内でキャッシュを管理してくれる。(やりようによっては正規化までサポートしている、素晴らしい) デフォルトだとインメモリキャッシュだが、DBを使用した永続化も可能。 透過的なキャッシュを実装する上では理想的だと思う。

GraphqlClientでキャッシュを永続化するにはHiveStoreを使う。 GraphqlFlutterであれば初期化処理は initHiveForFlutter()をmain.dartで呼べば済むが、Graphqlを使っている場合はもう少し手間がかかる。

一例だが下記のようになる。DB保存先のPathを取得し、HiveStoreをopenする。デフォルトでは 内部で保持しているBoxの名称がgraphqlClientStoreとなるが、 openの引数にnameがあるため変更もできる。 GraphQLClientの生成時のcacheにGraphQLCache(store: hiveStore)を入れればOK。

final appDir = await getApplicationDocumentsDirectory();
final path = appDir.path;
final store = await HiveStore.open(path: path);

final client = GraphQLClient(
        link: authLink.concat(_baseLink), cache: GraphQLCache(store: store));

RiverpodでDIしてる時

RiverpodでDIしている時は、graphqlClientもprovider経由で取得したい。 ただ、hiveStoreの初期化が非同期なので素直にやるとFutureProviderとなってしまい、使用する側がとてもめんどくさくなってしまう。 SharedPreferencesを使用する場合と同様の問題が起きる。

そこで hiveStoreを渡すようのproviderを作成し、main.dartでhiveStoreの初期化を実施、provderをProvderScope内でoverrideすることで非同期処理を吸収してしまう。

final hiveStoreProvider = Provider<HiveStore>((ref) {
  throw Exception('Provider was not initialized');
});

Future<void> main() async {
~~~~
  var appDir = await getApplicationDocumentsDirectory();
  var path = appDir.path;
  final store = await HiveStore.open(path: path);

  runApp(
    ProviderScope(
      overrides: [
        hiveStoreProvider.overrideWithValue(store),
      ],
      child: App(),
    ),
  );

~~~~
}

上記の対応をすることで、graphqlClientのproviderは下記のように同期的に呼び出せるようになる。

final gqlClientProvider = Provider((ref) => AppGqlClient((ref.read)));

最後に

GraphqlClient便利。