Swiftの文字列補間をカスタマイズする

以下の3つのProtocolを用いることで、Swiftの文字列補間(式展開, 変数展開などとも言う)をカスタマイズすることができる。

文字列リテラルで任意の型を初期化する

ExpressibleByStringLiteralは、準拠した型を文字列リテラルで初期化することを可能にするProtocolで、StringもこのProtocolに準拠している。

このProtocolを任意の型に準拠させることで、String同様に文字列リテラルで初期化することができる。ただ、このProtocolに準拠するのみだと文字列補間を扱うことはできない。

struct Note {
    let value: String
}

// 文字列リテラルでNoteをinitできるようになる
extension Note: ExpressibleByStringLiteral {
    init(stringLiteral value: StringLiteralType) {
        self.init(value: value)
    }
}

let note1: Note = "NOTE1"
print(note1.value)
// → NOTE

// 文字列補完は扱えない
let note2: Note = "NOTE\(2)"
// → error: cannot convert value of type 'String' to specified type 'Note'

文字列補間を含む文字列リテラルで任意の型を初期化する

文字列補間を可能にするためには、ExpressibleByStringInterpolationに準拠する必要がある。StringはこのProtocolに準拠しているため文字列補間を含む文字列リテラルで初期化できるようになっている。

上記のコードにおいて、NoteはこのProtocolに準拠しておらず文字列補間が行えないため、文字列リテラル "NOTE\(2)" はStringとして認識されて error: cannot convert value of type 'String' to specified type 'Note' というエラーが出力されている。

ExpressibleByStringInterpolationを任意の型に準拠させると、String同様に文字列補間を含む文字列リテラルで初期化できるようになる。

ちなみに、ExpressibleByStringInterpolationはExpressibleByStringLiteralを継承している。

struct Note {
    let value: String
}

// String同様の文字列補間をNoteのinitで使えるようになる
extension Note: ExpressibleByStringInterpolation {
    // MARK: - ExpressibleByStringLiteral
    init(stringLiteral value: StringLiteralType) {
        self.init(value: value)
    }
}

let note2: Note = "NOTE\(2)"
print(note2.value)
// → NOTE2

文字列補間をカスタマイズする

ExpressibleByStringInterpolationは以下のStringInterpolationというStringInterpolationProtocolに準拠したassociatedtypeを持ち、このStringInterpolationを拡張することで文字列補間をカスタマイズすることができる。

associatedtype StringInterpolation : StringInterpolationProtocol = DefaultStringInterpolation where Self.StringLiteralType == Self.StringInterpolation.StringLiteralType

例えば、StringのStringInterpolationを拡張することで、Stringの文字列リテラル内で下記のような文字列補間を有効にできる。具体的には、以下のようにappendInterpolationメソッドをoverloadすることで行う。overloadのため、任意の引数をもつメソッドを任意の数だけ追加でき、利用時にはコード補完もされる。

extension String.StringInterpolation {
    mutating func appendInterpolation(if bool: Bool, _ str: String) {
        if bool { appendLiteral(str) }
    }
}
print("\(if: true, "TRUE")")
// → TRUE

code completion
コード補完もされる

StringのStringInterpolationはDefaultStringInterpolationであるため、DefaultStringInterpolationが適用されている他の型でも同様の文字列補間を利用できる。

extension String.StringInterpolation {
    mutating func appendInterpolation(if bool: Bool, _ str: String) {
        if bool { appendLiteral(str) }
    }
}

struct Note {
    let value: String
}

extension Note: ExpressibleByStringInterpolation {
    // MARK: - ExpressibleByStringLiteral
    init(stringLiteral value: StringLiteralType) {
        self.init(value: value)
    }
}

let note: Note = "NOTE \(if: true, "TRUE")"
print(note.value)
// → NOTE TRUE

また、StringInterpolationを自作することで独自の文字列補間のみを利用することもできる。この場合、DefaultStringInterpolationで実装されている文字列補間は利用できなくなる。

struct Note {
    let value: String
}

extension Note: ExpressibleByStringInterpolation {
    // MARK: - ExpressibleByStringLiteral
    init(stringLiteral value: StringLiteralType) {
        self.init(value: value)
    }

    init(stringInterpolation: StringInterpolation) {
        self.init(stringLiteral: stringInterpolation.output)
    }
    
    struct StringInterpolation: StringInterpolationProtocol {
        var output = ""
        
        init(literalCapacity: Int, interpolationCount: Int) {
            output.reserveCapacity(literalCapacity)
        }
        
        mutating func appendLiteral(_ literal: String) {
            output.append(literal)
        }
        
        mutating func appendInterpolation(ifNot bool: Bool, _ str: String) {
            if !bool { appendLiteral(str) }
        }
    }
}

let note: Note = "NOTE \(ifNot: false, "FALSE")"
print(note.value)
// → NOTE FALSE

機能のモジュール化

機能単位でモジュール分割するにあたってどのように依存解決したら良いかを考えてみた。 以下のリポジトリであれこれしながら考えた。

github.com

機能モジュールとは

  • 機能単位でモジュールを作成していくこと
    • 上記の図のように Feature A 画面と Feature B 画面がある場合はそれぞれをモジュールにする
    • 複数の画面をまとめて単一のモジュールにしてもよい
    • Swift Package Manager を前提としている
  • ビルドがめちゃくちゃ高速化される
    • 機能単位でビルドできる
  • Xcode Preview が利用できるようになる
    • アプリを Run しなくてもレイアウトを確認できるようになる
  • 責務を自然と意識することになる

対応の方針

  • 開発速度が大きく改善されるので他のデメリットはある程度許容したい
  • モジュール数に制限は設けない
  • Xcode Preview を使えるようにする
  • 実装漏れは静的に検知できるようにする

モジュールの循環依存

  • 機能モジュールは循環依存の関係が生じやすい
    • Feature A.画面から Feature B 画面に遷移できる
    • Feature B 画面から Feature A 画面に遷移できる
  • 循環依存が生じているとビルドできない

Swift Package の依存関係
View の依存関係

// A Feature モジュール
import BFeature

public struct AView: View {
    func transition() -> {
        let bView = BView()
        let host = UIHostingController(rootView: bView)
        navigationController?.push(host)
    }
}

// B Feature モジュール
import AFeature

public struct BView: View {
    func transition() -> {
        let aView = AView()
        let host = UIHostingController(rootView: aView)
        navigationController?.push(host)
    }
}

インターフェースモジュールによる依存解決

  • 循環依存を防ぐためにインターフェースを分離してモジュール(Environment)とする
  • 機能モジュールはインターフェースを介して実体を取得する
  • App Target ではインターフェースの実体を提供する
    • 各 Feature に DI する
Swift Package の依存関係
View の依存関係
  • Environment には View のインスタンスを提供するインターフェース(View Buildable)を持つ
  • App では各 View Buildable に準拠した Builder を持つ
  • 各 Feature の View の初期化時に Builder を DI する
  • 各 Feature の View は Builder を介して他の Feature の View を取得する
  • Builder は Protocol Composition パターンで DI を実現する
    • 各 Feature で必要なインターフェースの実装漏れを静的に検知できる
    • 各 Feature で必要なインターフェースのみを公開できる
// Environment モジュール
public protocol AViewBuildable {
    func buildAView(id: Int) -> AnyView
}

public protocol BViewBuildable {
    func buildBView() -> UIViewController
}

// App
import AFeature
import BFeature

struct AppViewBuilder {}
extension AppViewBuilder: AViewBuildable {
    func buildAView(id: Int) -> AnyView {
        let viewModel = AViewModel(id: id)
        return AnyView(AView(builder: self, viewModel: viewModel))
    }
}
extension AppViewBuilder: BViewBuildable {
    func buildBView() -> UIViewController {
        let repository = BRepository()
        let viewModel = BViewModel(repository: repository)
        return BViewController(builder: self, viewModel: viewModel)
    }
}

// A Feature モジュール
import Environment

public struct AView: View {
    // 必要な View のインターフェースのみにアクセスを限定できる
    // DI された builder が指定したインターフェースを提供しない場合にはコンパイルエラーで検知できる
    typealias ViewBuildable = BViewBuildable & ...

    private let builder: ViewBuildable

    public init(builder: ViewBuildable, viewModel: View) {
        self.builder = builder
        self.viewModel = viewModel
    }

    func transition() -> {
        let bView = builder.buildBView()
        let host = UIHostingController(rootView: bView)
        navigationController?.push(host)
    }

Xcode Preview における実体の差し替え

Xcode Preview 用の Builder

  • Xcode Preview においても Builder の実態を View に提供する必要がある
  • ダミーの View を初期化して返すだけの Builder を Environment で用意する
    • 全ての機能モジュールで Xcode Preview する際に必要になる
    • 画面遷移先は View の実装で意識すべきでないのでダミーの View を返すだけで良い
    • ダミーの View で良いので共通化できる

Xcode Preview における Builder の実装

// Environment モジュール
public protocol XcodePreviewBuildable: AViewBuildable & BViewBuildable

// 他のモジュールから初期化できないように internal に
struct XcodePreviewViewBuilder: XcodePreviewBuildable {
extension XcodePreviewBuilder {
    func buildAView(id: Int) -> AnyView {
     AnyView(Color.green)
    }

    func buildBView() -> UIViewController {
        .init()
    }
}
// PreviewProvider 内でのみ PreviewBuilder を参照できるように
public extension PreviewProvider {
    static var previewBuilder: some XcodePreviewBuildable { XcodePreviewBuilder() }
}

// A Feature モジュール
import Environment

struct AView_Previews: PreviewProvider {
    static var previews: some View {
        AView(
   builder: previewBuilder, // Xcode Preview 用の Builder を DI
            viewModel: AViewModel(id: 1)
        )
    }
}

💡 Tips Generics View の body で @ViewBuilder をもつ View を使うと Xcode Preview がクラッシュするので @ViewBuilder をもつ View を作りたいときは @ViewBuilder なメソッドを用いる。(Xcode 13.3.1 で確認)

// ⛔️
struct FeatureView<T>: View {
    var body: some View {
        SubView { Text("クラッシュする") }
    }

    struct SubView<T: View>: View {
        @ViewBuilder let content: T
        var body: some View {
            content
        }
 }
}
// ✅
struct FeatureView<T>: View {
    var body: some View {
        subView { Text("クラッシュしない") }
    }

    func subView<T: View>(@ViewBuilder content: () -> T) -> some View {
        content()
    }
}

ViewModel の DI をしていく

ViewModel の DI ができないと困る

  • 任意の状態を再現するために ViewModel のロジックを把握して操作する必要がある
  • 全ての依存性の Mock を準備する必要がある
  • View の実装時はレイアウトのみに集中したい
final class AViewModel: ObservableObject {
    @Published private(set) var items: [Item] = []

    init(
        id: Int,
        repository1: Repository1Protocol,
        repository2, Repository2Protocol
    ) {
        self.id = id
        self.repository1 = repository1
        self.repository2 = repository2
    }

 func buttonDidTap() {
         ...
   // 任意の items の状態で Preview したいときに repository を操作する必要がある
   // View のレイアウト実装時に ViewModel のロジックを意識しないといけない
         items = repository.getItems()
         ...
     }
}

struct AView_Previews: PreviewProvider {
    static var previews: some View {
        // ViewModel の依存性の全てを準備する必要がある
        let repository1 = Repository1Mock()
        let repository2 = Repository2Mock()
        let viewModel = AViewModel(
            id: 1,
            repository1: repository2,
            repository2: repository2
        )
        AView(
   builder: previewBuilder,
            viewModel: viewModel
        )
    }
}

DI できるようにするために

  • Observable Object を継承する Protocol に準拠させる
  • @Published プロパティから viewModel.$items のような Publisherの生成ができなくなる
    • Protocol で Publisher のインターフェースを定義すれば良い
    • そもそも Publisher が必要なケースはなさそう
      • onReceive(_:perform:) が使いたい場合は onChange(of:perform:) を使えるはずで Publisher がそもそも不要になる
  • Binding は $viewModel.items ので作れる
protocol AViewModelProtocol: ObservableObject {
    var items: [Item] { get }
    func buttonDidTap()
}

final class AViewModel: AViewModelProtocol {
    @Published private(set) var items: [Item] = []

    init(
        id: Int,
        repository1: Repository1Protocol,
        repository2, Repository2Protocol
    ) {
        self.id = id
        self.repository1 = repository1
        self.repository2 = repository2
    }

  func buttonDidTap() {
         ...
         items = repository.getItems()
         ...
     }
}

struct AView_Previews: PreviewProvider {
    class ViewModel: AViewModelProtocol {
        let items = Array(repeating: Items(), count: 10)
        func buttonDidTap() {}
    }
    static var previews: some View {
        AView(
   builder: previewBuilder,
            viewModel: ViewModel()
        )
    }
}

機能モジュール作成手順 📝

Swift Package の作成

  • メニューから Swift Package を作る
    • File > New > Package
    • Add to は App
    • Group は Package
  • 不要なディレクトリ・ファイルを削除する(場合によって)
    • .gitignore
    • README.md
  • Package.swift を記述する
// swift-tools-version: 5.6

import PackageDescription

let package = Package(
    name: "Logger",
    platforms: [.iOS(.v14)],
    products: [ // ライブラリの定義。これを定義すると App Target や他の Swift Package から参照できる。
        .library(name: "Logger", targets: ["Logger"])
    ],
    dependencies: [ // 依存するライブラリの定義。
        .package(name: "Entity", path: "../Entity"),
        .package(name: "Extensions", path: "../Extensions")
    ],
    targets: [ // ライブラリに含めるターゲット。
        .target(
            name: "Logger",
            dependencies: ["Entity", "Extensions"],
            path: "./Sources"
        )
    ]
)

実装

  • View, ViewModel, Repository などを機能モジュールで実装する
  • Buildable(機能のインターフェース)を Environment に定義する
  • Builder で準拠する
    • AppViewBuilder(App Target における Buildable の実態)
    • XcodePreviewBuilder(Xcode Preview における Buildable の実態)

課題・理想

  • 各種 Assets の扱いについて
  • 複数の Package で使う外部依存性が重複する
  • モジュールのテストについて
    • CI では差分のあるモジュールを検知して該当モジュールのテストだけ実行するとよさそう
  • Xcode Preview Builder は自動生成したい

リソースファイルの扱い方についてメモ

参考

WWDC2021 で発表された App Store の In-App Events

Meet in-app events on the App Store の内容まとめ。 developer.apple.com

developer.apple.com

In-App Events とは

Meet in-app events on the App Store - WWDC21 - Videos - Apple Developer f:id:komaji504:20210630185445p:plain

  • 下記のようなタイムリーなイベントを App Store 上で紹介することができるようになる
    • ゲームの大会
    • フィットネスチャレンジ
    • 映画の初公開
  • プロダクトページ・Todayタブ・ゲームタブ・Appタブ・検索タブとあらゆるところで表示される

表示のされ方

イベントカード

Meet in-app events on the App Store - WWDC21 - Videos - Apple Developer f:id:komaji504:20210630191320p:plain

  • イベントはカード状のコンポーネントで表示される
    • 画像またはビデオ
    • イベント名
    • 簡単な説明
    • イベントが近づいたり開始したりすると自動的に更新される時間インジケータ
    • 開くボタン(アプリインストール済みのユーザ)
      • タップするとアプリ内のイベントページが開く
  • プロダクトページにおいては下記の位置に表示される
  • 検索タブにおいてイベントが表示され方は下記の通り
    • ダウンロード済みユーザであればスクリーンショットが表示される代わりにイベントが表示される
    • 未ダウンロードユーザであれば従来通りスクリーンショットが表示されてイベントは表示されない
    • イベントを直接検索した場合はユーザに関わらずイベントが表示される

詳細ページ

Meet in-app events on the App Store - WWDC21 - Videos - Apple Developer f:id:komaji504:20210630190040p:plain

  • イベントカードタップでイベント詳細ページに遷移する
  • イベント詳細ページにはより詳細な情報を持たせることができる
  • App Store URL にユニークなイベントIDを付与することでイベント詳細ページを直接開くこともできる

イベント開始通知

Meet in-app events on the App Store - WWDC21 - Videos - Apple Developer f:id:komaji504:20210630191752p:plain

  • ユーザがイベント開始通知を設定することができる
  • イベント開始通知は App Store から送信される
  • 通知からアプリ内のイベントページを直接起動できる
  • ユーザがアプリをインストールしていない場合はイベントの詳細ページが開く

イベント公開フロー

Meet in-app events on the App Store - WWDC21 - Videos - Apple Developer f:id:komaji504:20210701163046p:plain

  • イベント作成して保存するとドラフト状態になる
  • 公開するにはレビューに提出する必要がある
    • 全てのメタデータを入力するとレビューに提出できる
    • 新規アプリバージョンやバイナリのアップロードは不要
  • レビューが通ると設定したスケジュールに沿って自動で公開される
  • レビューが通った状態のイベントを最大で10個まで保持することができる
  • 最大で5個まで同時に公開できる
  • 上記を満たすことができない設定のイベントはレビューに提出できないようになっている
  • イベント公開日時はイベント開始前の14日以内を指定できる
  • イベント終了日時はイベント開始後の31日以内を指定できる
  • イベント終了日に達すると App Store に公開されなくなる
    • イベント終了から30日後まで App Store のリンクからは開くことができる点に注意する

イベントのメタデータ

Meet in-app events on the App Store - WWDC21 - Videos - Apple Developer f:id:komaji504:20210630192459p:plain

  • イベント参照名
    • App Store Connect 内でのみ表示される名前
    • 最大で64文字
  • イベント名
    • ユーザに見えるイベントの名前
    • 最大30文字
  • 簡単な説明
    • 最大で50文字
    • イベントカードに表示される
  • 詳細な説明
    • 最大で120文字
    • イベント詳細ページに表示される
  • 画像または動画
    • イベントカード用とイベント詳細ページ用のそれぞれ用意する
    • 動画は最大30秒
    • 動画はループして再生される
  • バッジ(オプション)
    • イベントの種類を表す
      • Challenge
      • Competition
      • Live Event
      • Major Update
      • New Season Premiere
      • Special Event
    • イベントカードとイベン詳細ページに表示される
  • 国と地域
    • アプリを提供している国と地域の中から選択できる
  • イベント開始日時
    • アプリ内のイベント開始日時
  • イベント終了日時
    • アプリ内のイベント終了日時
    • イベント開始後の31日以内を指定できる
  • イベント公開日時
    • イベント開始前の14日以内を指定できる
  • ディープリンク
    • ユニバーサルリンクまたはカスタムURL
    • URLの短縮やリダイレクトを行わせるなどのサービスの利用は避ける
  • イベントの目的
    • App Store 上のパーソナライズに用いられる
    • 多くの場合は全てのユーザに向けたイベントであるのでそれがデフォルトになっている
      • 全てのユーザ
      • 新規ユーザ
      • 既存ユーザ
      • 離脱ユーザ
    • 目的を限定しても検索等で全てのユーザが閲覧できることに注意する
  • イベントの優先度
    • 2種類ある
    • プロダクトページ上で優先度の高い順に並ぶ
      • 同一優先度の中では開始日が近い順
  • アプリ内購入またはサブスクリプションの必要有無
    • イベントカードとイベント詳細ページに表示される
  • 第一言語
    • デフォルトではアプリと同じ設定になっている

App Analytics で計測できること

  • AppStoreでイベントを見た人の数
  • ユーザがイベントを閲覧した場所
    • 商品ページ
    • 検索
    • その他の場所など
  • アプリを開いたユーザー数
  • イベントの詳細ページを開いたユーザー数
  • イベントからアプリをダウンロードまたは再ダウンロードしたユーザー数
  • イベント通知を有効にしたユーザー数

WWDC2021 で発表された App Store プロダクトページのカスタマイズと最適化

Get ready to optimize your App Store product page の動画の内容まとめ。

developer.apple.com

この発表では、 App Store のプロダクトページにおける新機能である下記の2つの機能について紹介している。

  • 任意の目的のためのプロダクトページカスタマイズ
  • App Store 内の検索から訪れたユーザに表示されるプロダクトページの最適化

これらの機能の概要は後述するとおりで、今年の後半に利用できるようになるとのこと。

任意の目的のためのプロダクトページカスタマイズ

Get ready to optimize your App Store product page - WWDC 2021 - Videos - Apple Developer

カスタマイズイメージ
カスタマイズイメージ

  • プロダクトページの要素を任意の組み合わせで配置できるようになる
  • カスタマイズしたページの固有の URL を作成することができる
  • App Analytics では URL ごとの数値を確認することができる
  • 上記を活用して任意の目的専用のカスタマイズページを作成して効果検証することができる
  • 最大で35ページ作成できる
  • ページの作成にはアプリの申請は不要
  • ページの作成はアプリの申請とは異なる申請が必要
  • ページ作成の申請が承認されるとURLが発行される

App Store 内の検索から訪れたユーザに表示されるプロダクトページの最適化

Get ready to optimize your App Store product page - WWDC 2021 - Videos - Apple Developer

最適化イメージ
最適化イメージ

  • App Store 内の検索から訪れたユーザに対して表示されるデフォルトのページを最適化するための仕組み
  • カスタム製品ページ同様の機能に加えてアプリアイコンもカスタマイズ可能
  • デフォルトのページを除いて最大で3種類のパターンを同時に比較できる
  • それぞれのカスタマイズを適用するユーザの割合を指定することで、指定した割合をカスタマイズページ数で等分した割合で適用される
  • アプリアイコンをカスタマイズするには、全てのカスタマイズアイコンをバイナリに含める必要がある
  • ホーム画面に表示されるアイコンも、製品ページのアイコンと同じになる
  • 特定のローカライゼーションにのみ対象を絞ることもできる
  • App Analytics でそれぞれのパターンの数値を確認できる
  • 良い結果のパターンをデフォルトのパターンに適用する場合は、次のリリース時にデフォルトのアプリアイコンの変更を忘れないように注意する

SwiftUI で画面下部にボタンを置く

NG

画面下部にボタンを置く場合、 bottom の Safe Area に被らないように置かないといけない。

何も考えずに置くと、下記のように Safe Area まで拡張されずに残念な感じになる。

VStack(spacing: .zero) {
    List(0..<100) { index in
        Text("\(index)")
    }

    Button("Button", action: {})
        .frame(maxWidth: .infinity, minHeight: 44.0)
        .background(Color.orange)
}

f:id:komaji504:20210604202544j:plain:w375

NG

Safe Area までコンテンツを拡張しようと思って VStack に .ignoresSafeArea(edges: .bottom) をつけると、確かに拡張はされているが Safe Area に表示されるホームインジケータと位置が被ってしまって Human Interface Guidlines 的にも NG。

f:id:komaji504:20210604202540j:plain:w375

VStack(spacing: .zero) {
    List(0..<100) { index in
        Text("\(index)")
    }

    Button("Button", action: {})
        .frame(maxWidth: .infinity, minHeight: 44.0)
        .background(Color.orange)
}
.ignoresSafeArea(edges: .bottom) // NG

OK

ボタンの背景色だけを Safe Area まで拡張するには、下記のように .background() で指定している Color に対してだけ .ignoresSafeArea(edges: .bottom) を指定してやれば良い。

VStack に対しては .ignoresSafeArea(edges: .bottom) を指定しないのがキモ。

VStack(spacing: .zero) {
    List(0..<100) { index in
        Text("\(index)")
    }

    Button("Button", action: {})
        .frame(maxWidth: .infinity, minHeight: 44.0)
        .background(Color.orange.ignoresSafeArea(edges: .bottom)) // OK
}

f:id:komaji504:20210604202535j:plain:w375

SwiftUI のボーダーボタン と clipped

四角ボタン

四角のボタンにボーダーをつける場合は .border modifier を指定すれば作れる。

Button("四角のボーダーボタン", action: {})
    .frame(height: height)
    .padding()
    .background(Color.orange)
    .border(Color.red, width: borderWidth)

f:id:komaji504:20210528153240p:plain
四角のボーダーボタン

角丸ボタン

角丸のボタンにボーダーをつける場合には Button.init(action:,label) の label に Text を置いて、 .overlay modifier で ボーダーを引いた Shape を指定すれば作れる。

Button(
    action: {},
    label: {
        Text(“角丸のボーダーボタン”)
            .frame(height: height)
            .padding()
            .background(Color.orange)
            .cornerRadius(radius)
            .overlay( 
                RoundedRectangle(cornerRadius: radius)
                    .stroke(Color.red, lineWidth: borderWidth)
            )
    }
)

f:id:komaji504:20210528153709p:plain
角丸のボーダーボタン

四角同様に .border modifier だけで作ろうとしても、角丸にするための .conerRadius modifier では下記のように四角いボーダーがついてしまうので NG。

Button("角丸のボーダーボタン", action: {})
    .frame(height: height)
    .padding()
    .background(Color.orange)
    .cornerRadius(radius)
    .border(Color.red, width: borderWidth)

f:id:komaji504:20210528154008p:plain
NG 角丸のボーダーボタン

角丸ボタンの clipped

Button が clipped されるようなレイアウトを作る場合、Button に padding を付与する必要がある。 .stroke modifier で作られたボーダーは、 Button の edge をセンターとしてそこから左右に広がるように描画されるため、 Button のサイズで clipped されるとボーダーが見切れることになってしまう。

Button(
    action: {},
    label: {
        Text(“角丸のボーダーボタン”)
            .frame(height: height)
            .padding()
            .background(Color.orange)
            .cornerRadius(radius)
            .overlay(
                RoundedRectangle(cornerRadius: radius)
                    .stroke(Color.red, lineWidth: borderWidth)
            )
    }
)
.padding(borderWidth / 2.0) // <- これがないとボーダーが見切れる
.clipped()

f:id:komaji504:20210528154126p:plain
clipped 角丸のボーダーボタン

The Composable Architecture(TCA)メモ

ViewStore

binding

public func binding<LocalState>(
    get: @escaping (State) -> LocalState,
    send localStateToViewAction: @escaping (LocalState) -> Action
) -> Binding<LocalState>

https://github.com/pointfreeco/swift-composable-architecture/blob/main/Sources/ComposableArchitecture/ViewStore.swift#L125-L141

ViewStore の State, Action から Binding を作る。

双方方向バインディングをしたい時に使う。

Binding の getter では state の値を返し、setter では渡されたアクションを send する。

// 例
struct State { var name = "" }
enum Action { case nameChanged(String) }

TextField(
    "名前を入力してください",
    text: viewStore.binding(
        get: { $0.name },
        send: { Action.nameChanged($0) }
    )
)

Reducer

pullback

public func pullback<GlobalState, GlobalAction, GlobalEnvironment>(
    state toLocalState: WritableKeyPath<GlobalState, State>,
    action toLocalAction: CasePath<GlobalAction, Action>,
    environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment
) -> Reducer<GlobalState, GlobalAction, GlobalEnvironment>

https://github.com/pointfreeco/swift-composable-architecture/blob/main/Sources/ComposableArchitecture/Reducer.swift#L250-L264

子の Reducer を親の State, Action に紐づいた Reducer へ変換する。

これにより、単一の Reducer が大きくなりすぎてしまわないように適切に子 Reducer へと分割し、それらをマージして親 Reducer を作ることができる。

// 例
// 親の State
struct AppState { var settings: SettingsState, ... }
// 親の Action
enum AppAction { case settings(SettingsAction), ... }

// 子の Reducer
let settingsReducer = Reducer<SettingsState, SettingsAction, SettingsEnvironment> { ... }

// 親の Reducer
// combine で複数の子 Reducer を結合する
let appReducer = Reducer<AppState, AppAction, AppEnviroment> = .combine(
    settingsReducer.pullback(
        state: \.settings,
        action: /AppAction.settings,
        environment: { $0.settings }
    ),
    // 他の子 Reducer ...
)

combine

public static func combine(_ reducers: [Reducer]) -> Reducer

https://github.com/pointfreeco/swift-composable-architecture/blob/main/Sources/ComposableArchitecture/Reducer.swift#L155-L159

複数の Reducer を順番に実行する単一の Reducer を作る。

順番に実行するため配列の記述順序に影響し、親・子関係の Reducer が同一の State, Action を使用する際に問題を生じる場合がある。

特に Optional な Reducer を用いる場合に起こりやすく、子 Reducer の前に 親 Reducer を実行すると assertion failure となる可能性があるため、子から親という順序にするのが一般的である。

// 例
let parentReducer = Reducer<ParentState, ParentAction, ParentEnvironment>.combine(
    // 後続の Reducer で state を nil にしているが先に実行されるので state を処理できる
    childReducer.optional().pullback(
        state. \.child,
        action: /ParentAction.child,
        ennvironment: { $0.child }
    ),
    // childReducer の後に実行されるので state を nil にしても childReducer には影響がない
    Reducer { state, action, environment in
        switch action {
        case .child(.dismiss):
            state.child = nil
            return .none
        ...
        }
    }
)