iOS16 から使える SwiftUI の機能をおさらいする

はてなのマンガアプリチームで GigaViewer for Apps を作っています、 id:fxwx23 です。

タイトルを見て今更?と思ったかもしれませんが、iOSのアップデートで追加される機能の多くはバックポートされていないため、アプリ側のサポートする下限のOSバージョンが追いついて初めて利用可能になります。その結果、WWDCで発表された機能が実際の日々のプロダクト開発に取り入れられるまでには、2〜3年かかることがあります。

2022年に発表された iOS16 で SwiftUI に追加された機能は、ユーザー体験の向上やコードをシンプルにするために役立つものが多くあります。このタイミングであらためて見直すことで鮮度を保ったまま実践投入することができるのではと思い、今回はその中から個人的におさらいしておきたい機能をピックアップして紹介します。

lineLimit(_:)

View の中で Text が占有できる最大行数を設定するモディファイアです。iOS 15までは Int? を受け取る形のみでしたが、細かい進化を遂げています。

範囲型のサポート

iOS16 で TextField に複数行入力サポートが入ったため、範囲型を受け付けるようになっています。指定した範囲よりもスペースが必要になるとスクローラブルになってくれます。チャットUIの入力部分などがイメージしやすいと思います。

Form {
    TextField("Bio", text: $bio, axis: .vertical)
        .lineLimit(3...)
}

テキストフィールドの高さも自動で追従してくれます

余白空間を保持

指定した行数に満たなくても想定されるスペース分を確保してくれるようになっています。Grid レイアウトで各要素に表示する説明文は最大行数は指定したいけど、要素自体の高さは均一にしたい時などに使えますね。地味に嬉しい...

developer.apple.com

Text("description here")
        .font(.subheadline)
        .lineLimit(2, reservesSpace: true)

contentTransition(_:)

View のコンテンツの変更をアニメーション化させることができるモディファイアです。また一部をアニメーションさせないということも可能です。

developer.apple.com

例えば、数値のカウントアップやカウントダウンの演出のためににいい感じのアニメーションをつけることができます。コンテンツのカウント数が上限するタイミングでアニメーションがあるとおしゃれですね。

VStack {
    Text("\(count)")
        .contentTransition(.numericText())
    Button("Count Up") {
        withAnimation {
            count += 1
        }
    }
}

numericText(coundDown: false)

defersSystemGestures()

OS独自の組み込みジェスチャー(コントロールセンターや通知センター)よりもジェスチャーが優先されるように要求できるモディファイアです。これはユーザーが頻繁にスワイプする可能性のあるゲームや、画面の端に独自のジェスチャーを置く場合などに良さそうです。例えば Edge.Set.vertical で有効にすると Home Indicator がグレーアウトします。

developer.apple.com

ちなみに完全に非表示にしたりできる persistentSystemOverlays(_:)) も iOS16+ です。UIKit でできたことも SwiftUI で完結するようになってきています。

ViewThatFits

ViewThatsFits は複数の View の中から親 View のレイアウト条件に最も適した View を選択して表示してくれます。レスポンシブなデザインや、画面サイズやレイアウトの制約に応じた View の選択を簡素化するのに役立ちますね。

developer.apple.com

例えば、 View 内に収まるテキスト量なら Text で、収まらない場合はスクローラブルに表示したいというシーンを考えます。ViewThatsFits を使うと振り分けたい要素を優先順位順に書いておけばOKです。楽ちん!

struct ContentView: View {
    let text = String(repeating: "Long text ...", count: 100)

    var body: some View {
        ViewThatFits(in: .vertical) {
            // 収まる場合はText
            Text(text)
            // 収まらない場合はScrollView
            ScrollView {
                Text(text)
            }
        }
        .padding()
    }
}

デフォルトでは ViewThatFits にテキストの水平軸と垂直軸の両方を気にするようになっているため、常に ScrollView が選択されてしまいます。 この例の場合は垂直軸のみを測定するように制限する必要があるので注意が必要です。

収まる場合は Text

収まらない場合は ScrollView

AnyLayout

最後は AnyLayout です。AnyLayout は異なるレイアウト( HStackVStack など)を動的に切り替えるための型消去ラッパーです。SwiftUI では通常レイアウトを静的に決定しますが、AnyLayout を使用すると条件に応じてレイアウトを動的に変更することができます。

developer.apple.com

horizontalSizeClass や、ダイナミックフォントに応じてレイアウトを切り替えたい時に便利です。AnyLayout なら subview の状態を破壊せずにレイアウトコンテナの型を動的に変更できるのでいいですね。 if 分岐がなくなるのも良い。

struct ContentView: View {
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass

    var body: some View {
        let layout = horizontalSizeClass == .compact
        ? AnyLayout(VStackLayout())
        : AnyLayout(HStackLayout())

        VStack {
            layout {
                Rectangle()
                    .fill(.red)
                    .frame(maxWidth: .infinity)
                    .frame(height: 200)

                VStack(spacing: 0) {
                    Rectangle()
                        .fill(.blue)
                        .frame(maxWidth: .infinity)
                        .frame(height: 100)

                    Rectangle()
                        .fill(.green)
                        .frame(maxWidth: .infinity)
                        .frame(height: 100)
                        .background(.blue)
                }
            }

            Spacer()
        }
        .padding()
    }
}

horizontalSizeClass == .compact

horizontalSizeClass != .compact

Layout プロトコルに準拠したものなら利用できるので、自前のカスタムレイアウトと組み合わせることもできます。早く使いたいですね!


ここまでいくつかの機能を紹介しましたが、もちろんこれがすべてではありません!まだまだ活用しきれていない面白い機能がたくさんあるのでぜひ調べて活用してみてください。iOS 16 以降の機能を再発見する良いきっかけになれば幸いです。

この記事は はてなエンジニア Advent Calendar 2024 の23日目の記事でした。明日は id:masawada さんです!