22
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Swift UI (アノテーション・データバインディング・UI部品Tips) ひとまとめ

Last updated at Posted at 2023-01-16

AnyView

@ViewBuilder

  • 以下、引用

複数画面で共通のナビゲーションバーやボタンを使う場合、毎回同じViewやmodifierを書くのは煩雑なので、共通のViewとして定義しておけば簡単に流用できる。そこでViewBuilderを使えば、HStackやVStackのようにsubviewsを構築するViewを定義できる。
ViewBuilderとは、クロージャーから複数のViewを構築するカスタムパラメータ属性で、複数のViewをTupleViewという型にまとめて返却する。

.swift
/// 共通のNavigationStack
struct CommonNavigationStack<Content: View>: View {
    let content: Content
    let toolBarColor = Color.pink
    
    // イニシャライザのパラメータに@ViewBuilderを付けることで、複数のViewから構築できるようになる
    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    var body: some View {
        // 共通のNavigationStackに各種設定を適用
        NavigationStack {
            content
                .navigationBarTitleDisplayMode(.inline)
                .toolbarBackground(toolBarColor, for: .navigationBar)
                .toolbarBackground(.visible, for: .navigationBar)
                .toolbarColorScheme(.dark, for: .navigationBar)
        }
    }
}

struct FirstView: View {
    var body: some View {
        // 共通のNavigationStackの中にViewを記述すれば、上で定義した各種設定が反映される
        CommonNavigationStack {
            VStack {
                Text("First View")

                NavigationLink {
                    SecondView()
                } label: {
                    Text("Show Second")
                }
                .padding()
            }
            .navigationTitle("First")
        }
    }
}

struct SecondView: View {
    var body: some View {
        // ここでも共通のNavigationStackを使えば、簡単に共通のナビゲーションバーを適用できる
        CommonNavigationStack {
            Text("Second View")
                .navigationTitle("Second")
        }
    }
}

カスタムViewModifier

  • 簡単なものならViewのextensionに関数を追加する形でカスタムViewModifierを作れる。ただ状態保持が必要なものなど、複雑なものの場合は、カスタムViewModifierを利用する。ただし、公式ドキュメントでもあるように、カスタムViewModifierを利用する場合でも、extensionでラップするとよい。
  • ViewBuilderやViewModifierでSwiftUIのViewを分割する

SwiftUIからUIkitを使う <-> UIKitからSwiftUIを使う

SwiftUIからUIkitを使う

UIKitからSwiftUIを使う

  • UIHostingControllerを使う。

    -> UIHosingControllerに何らかの状態を持たせたいときは

    • たとえば、UIHosingControllerのインスタンスをあるViewControllerで保持しておき、ViewController上で何か状態の変更があったら、UIHosingControllerのインスタンスのrootViewプロパティを更新することで、UIHosingControllerが保持しているSwiftUI Viewの中身を変更することができる。

    • あるいは、UIHostingControllerに保持させるSwiftUI ViewへObservableObject(後述)を渡しておけば、何らかの状態が変更されたときにその変更がSwiftUI Viewへ伝わるし、逆にSwiftUI View側で状態を変更したときは、それが外部にも伝わるということになる。

    • あるいは、ObservableObjectではなく、CombineのPublisherを渡すこともできる。

    • 以下、引用

MessageView.swift
struct MessageView: View {
    let publisher: AnyPublisher<String, Never>

    @State private var message: String = ""

    var body: some View {
        Text(message)
            .onReceive(publisher) { message = $0 }


    }
}

- 以上、引用 SwiftUIとUIKitを仲良くさせるより

@Binding

Binding

Local Binding (Custom Binding)

Bindingとは構造体であって、以下のようにイニシャライザも用意されている。これを利用することで、値のget, setの際に行うべき動作をカスタマイズさせることもできる。

init(get: @escaping () -> Value, set: @escaping (Value) -> Void)
  • 以下、引用

アプリの開発をすすめると 実際にはもう少し複雑なケースがでてきます。
例えば、実際には Int で管理している数値を スライダーUIを使用して変更したいときです。
Slider が受け取る Binding は、Double 等の BinaryFloatingPoint に準拠している必要があります。
Double や Float は BinaryFloatingPoint に準拠していますが、Int は、準拠していません。つまり、Int の Binding は渡すことができないということです。
そのほかにも、実際には指定された値を調整してからデータとして保持したいとか、表示する時に特定の加工を行ったものを表示したい等があります。そんな時には、ローカルに Binding を作って渡すことができます。Apple のドキュメントを見てみると、Binding は、struct であることもわかります。
initializer を見てみると、Binding を受け取るものや projectedValue を受け取るものと並んで、get/set を指定する initializer が見つかります。この initializer を使って、Binding を作成することになります。

ContentView.swift
//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/03/16
//  © 2022  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @State private var amount: Amount = .medium

    var body: some View {
        VStack {
            Text("Selection: \(amount.description)")
            Slider(value: Binding(get: {
                return amount.representValue
            }, set: { newValue in
                if Amount.smallRange.contains(newValue) {
                    amount = .small
                } else if Amount.mediumRange.contains(newValue) {
                    amount = .medium
                } else {
                    amount = .large
                }
            }))
        }
        .padding()
    }

    enum Amount: CustomStringConvertible {
        static let smallRange = 0..<0.25
        static let mediumRange = 0.25..<0.75
        static let largeRange = 0.75...1
        case small, medium, large
        var description: String {
            switch self {
            case .small:
                return "Small"
            case .medium:
                return "Medium"
            case .large:
                return "Large"
            }
        }
        var representValue: Double {
            switch self {
            case .small:
                return 0.15
            case .medium:
                return 0.5
            case .large:
                return 0.85
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

init(get: @escaping () -> Value, set: @escaping (Value, Transaction) -> Void)

こちらのイニシャライザは一つ上のやつと違って、setterのところにTransactionという引数が追加されている。

animation(_:)

Bindingの変更がUIに変更される際に、アニメーションを伴って変更してほしい場合は、animation(_:)メソッドを用いる。

@ObservedObject, ObservableObject, @Published

@Stateと同様、データバインディングの仕組みの一つで、データクラスの更新を監視する。

  • 以下、引用

@StateではViewのプロパティに対してその仕組みを実現しますが、データクラスに対しては@ObservedObjectを使用します。

ポイントは、次の3点です。
データクラスはObservableObjectプロトコル準拠とする。
監視対象とするプロパティに@Published属性を付加する。
データクラスのインスタンスは@ObservedObject属性を付加してViewの中で宣言する。

UserView.swift
/// ユーザークラスの定義
class User: ObservableObject {
    @Published var name = ""
    @Published var level = 1
}
 
struct ContentView: View {
    @ObservedObject var user = User()   // インスタンスを生成
    var body: some View {
        VStack {
            Text("\(user.name)さんのレベルは\(user.level)です")
            
            /// 入力フォーム
            TextField("名前", text: $user.name)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .frame(width: 300)
                .padding()
            Button("レベルアップ") {
                user.level += 1
            }
        }
    }
}

@Bindingとの違い

@ObservedObjectを丸々子Viewに渡し、子ビューの側でも@ObservedObjectマークをつければ、子ビューの側ともバインディングすることができる。

@Bindingも同様の機能を果たすが、@BindingはObservableObjectに準拠していないような型(例えば、String, Int, その他自作クラスなど)を子ビューに渡してバインディングさせたいような時に使うので、@ObservedObjectとはこの点で使い分ける。

【SwiftUI】@ObservedObjectの使い方を徹底解説

@State, @Binding, @StateObject, @ObservedObject 使い分け

  • @State: データが値型で、データの発生源がView自身の場合

  • @Binding: 値型のデータで、データの発生源は親Viewなど外から渡される場合

  • @StateObject: 参照型データオブジェクトを扱い、データの発生源はView自身の場合

  • @ObservedObject: 参照型データオブジェクトを扱い、データの発生源は親Viewなど外から渡される場合

    • ObservedObjectとStateObjectの違いについて混乱するかと思いますが、ObservedObjectは該当ViewのBodyに何らかの変更が加わるたびにリセット・再生成されます。そのため子View自身が保持してほしいデータについてObservedObjectとしてしまうと、親ViewのBodyに何か変更があるたびに子Viewのボディも変更されるため、そのデータがリセットされてしまいます。そのため、子View自身の管理下に置いておきたいデータはStateObjectとすべきです。StateObjectであれば、データが保持されます。子View自身の管理するデータであるため、Stateと同様、privateマークをつけておくべきです。
  • SwiftUIのデータ管理 Property Wrapper編

@State@StateObjectの使い分け 補足

上に述べたように基本的に@Stateは値型、@StateObjectは参照型の変数につけていくことになる。公式リファレンスにもそう書いてある。

また少し特別な場合として、Observableプロトコルに準拠するような変数につける場合は(値型であっても)@State, ObservableObjectプロトコルに準拠する様な変数につける場合は@StateObjectとも述べられている。ObservableObjectはiOS13の頃からあるが、ObservableはiOS17から追加された比較的新しいものである。

補足としては、@Stateは、変数の参照が丸ごと入れ替わったような時にのみ反応し、変数であるクラス、構造体の中身の変数に何か変更があっただけでは何も反応しないとしている。これとは対照的に、@StateObjectは双方の場合に反応するとしている。だからこそ、クラス、構造体の中身の変数に何か変更があった時に変更の合図を出せるObservableObjectに準拠している型の場合は、@StateObjectを用いろということだろう。

@EnvironmentObject

機能としては@ObservedObjectと同じく、子Viewに情報をBindingする時に親Viewから渡すものですが、間に何個も子Viewが挟まると目的の孫ビューまでいちいち伝言ゲームのように渡していくのはいかにも無駄なことがあります。その場合、孫ビューから直接参照できるように親ビューで設定しておくことができます。

親ビュー側では、親ビューを初期化する際に親ビューインスタンスのenvironmentObject(_)メソッドを呼び、目的のオブジェクトを渡しておく必要があります。渡していないとランタイムエラーとなります。

孫ビュー側では、@EnvironmentObjectマークをつけた上で、目的のオブジェクトに直接アクセスできます。

@AppStorage

UserDefaultに直接アクセス&データに変更があればViewに反映される便利なマーク。
AppStorage
What is the @AppStorage property wrapper?

@AppStorage の変数について、初期値 を設定すると、UserDefaultに値がない時のみ初期値として使われる。すでにUserDefaultsに値がある場合は、それが使われ、初期値は無視される。例えば、以下の「10」は、すでにUserDefaultsに値がある場合は無視される。

test.swift
    @AppStorage("hoge") private var hoge = 10

@SceneStorage

Scene内部で、軽いデータを共有するために用いることができるマーク。Scene外にはデータを伝えることはできない。Sceneを破棄するとこのデータも破棄される。

SceneStorage
Introducing @SceneStorage in SwiftUI

PropertyWrapper 全部 使い分け

@UIApplicationDelegateAdaptor

SwiftUI AppからAppDelegateを使いたいときに利用する。

Main.swift
@main
struct MainApp: App {
    
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

@GestureState

@Stateと似たような立ち位置だが、ドラッグなどジェスチャーの位置により何かUIを変化させたいときに用いる。例えば、ドラッグの位置にあるViewを追随させるなど。@Stateよりパフォーマンスが良く、またジェスチャーが終わった場合は自動で元の位置に戻るという利点がある。

What is the @GestureState property wrapper?

使用時はupdating(_:body:)というメソッドと組み合わせて用いる。

example.swift
struct SimpleLongPressGestureView: View {
    @GestureState private var isDetectingLongPress = false


    var longPress: some Gesture {
        LongPressGesture(minimumDuration: 3)
            .updating($isDetectingLongPress) { currentState, gestureState, transaction in
                gestureState = currentState
            }
    }


    var body: some View {
        Circle()
            .fill(self.isDetectingLongPress ? Color.red : Color.green)
            .frame(width: 100, height: 100, alignment: .center)
            .gesture(longPress)
    }

@FocusState

@Stateの派生系で、テキストフィールドなどが画面上に複数ある場合、どのテキストフィールドにフォーカスを合わせキーボードを出していくのかを制御するためのもの。

テキストフィールドなどが2つしかない場合は、単にBool型の変数に@FocusStateをつけることになる。

逆に3つ以上の場合は、ケースが3つ以上ある専用のenumを作り、そのenumを型とする変数に@FocusStateをつけることになる。

  • 以下、引用
ContentView.swift
struct ContentView: View {
    @FocusState private var isUsernameFocused: Bool
    @State private var username = "Anonymous"

    var body: some View {
        VStack {
            TextField("Enter your username", text: $username)
                .focused($isUsernameFocused)

            Button("Toggle Focus") {
                isUsernameFocused.toggle()
            }
        }
    }
}
ContentView.swift
struct ContentView: View {
    enum FocusedField {
        case username, password
    }

    @FocusState private var focusedField: FocusedField?
    @State private var username = "Anonymous"
    @State private var password = "sekrit"

    var body: some View {
        VStack {
            TextField("Enter your username", text: $username)
                .focused($focusedField, equals: .username)

            SecureField("Enter your password", text: $password)
                .focused($focusedField, equals: .password)
        }
        .onSubmit {
            if focusedField == .username {
                focusedField = .password
            } else {
                focusedField = nil
            }
        }
    }
}

上記を踏まえて、いろいろなViewから使いまわせる部品のような子Viewを作るとすると:

ParentView.swift
struct ParentView<Presenter>: View where Presenter: ParentPresenterProtocol {
    @StateObject var presenter: Presenter

    var body: some View {
        VStack(spacing: 0) {
            title
            childABody
            childBBody
        }
    }

    @ViewBuilder
    var title: some View {
        Text("title")
    }

    @ViewBuilder
    var childABody: some View {
        Button {
            presenter.sendEvent(.childA)
        } label: {
            Text("ChildA")
        }
    }

    @ViewBuilder
    var chilBABody: some View {
        Button {
            presenter.sendEvent(.childB)
        } label: {
            Text("ChildB")
        }
    }
}

Observation系列

長々解説してきたが、2023年以降(iOS17以降)はObservedObjectではなく、新登場のObservation(@Observableマクロ)を用いた方が簡単かつパフォーマンスが良さそう。

注意点としては

  • @Observableマクロをつける対象はclass(なので、@ObservableObjectの代替となる)。プロパティに@Publishedなどと言うマークはいらない。プロパティをただ宣言するだけでView側からの購読対象とできる。
ContentViewModel.swift
@Observable
final class ContentViewModel {
    var model: ContentModel = .init()
    // do something
}
  • @Observableマクロをつけた対象のクラスの中で複数プロパティがある場合、あるプロパティが更新された場合はそこに関連するSwift UI ビューの部品のみが更新され、別のプロパティに基づく部品は更新されないので、パフォーマンスに良い。

  • View側では、「@StateObject」ではなく「@State」をつけてプロパティとして宣言する。それだけで購読できる。

なお、iOS17から追加された「@Bindable」をつけて宣言すれば、双方向バインディングとできる。

ContentView.swift
@MainActor
struct ContentView: View {
    // これだけでOK
    @State private var viewModel: ContentViewModel = ContentViewModel()
    
    var body: some View {
        VStack {
            Image(viewModel.model.handState.imageName ?? "", bundle: nil)
                .resizable()
                .layoutPriority(1)
                .padding(.bottom, 5)
            Text(viewModel.model.handState.text)
                .frame(height: 60)
                .padding(.bottom, 5)
            Button(action: {
                viewModel.onTapButton()
            }, label: {
                Text("Please tap")
                    .frame(height: 60)
            })
        }
        .padding()
    }
}

詳細は:

さようなら、Combine。そしてこんにちは、Observation。

PropertyWrapperとは

getter や setterを いちいち書かずに済むよう、アノテーションマークをいっこ書くだけで済むようにするための仕組み。SwiftUIの@Stateなど数々のマークで使われている。

もちろんPropertyWrapperとは自体は@Stateなどのマークとは別個の仕組みであるので、自分でオリジナルのPropertyWrapperを作ることもできる。 例えばUserDegfaultsで毎回getterやsetterを書いていたところを簡単に書けるようにするなどの目的で利用できる。その他、変数のセットやゲットの時に何かの制御をさせたいようなあらゆる場面で利用できる。

利用時には、class, struct, enumに@PropertyWrapperマークをつけると同時に、任意の型のwrappedValueという変数を設ける。

projectedValueは、PropertyWrapperの値と関連した何らかの任意の型の値を返させるために使う。使わなくても良い。$マークを変数につけてアクセスできる。

Properties - Swift.org

DynamicMemberLookUp とは

定義されていない名前の変数でもあたかも存在するかのように動的に値を返せる仕組み。SwiftUIの@Stateなど数々のマークで使われている。

DynamicMemberLookUp自体は@Stateなどのマークとは別個の仕組みである。そのため自分でオリジナルのDynamicMemberLookUpに準拠した型を作ることもできる。が、自分で利用する機会はそれほどないかもしれない。ライブラリなどを作成するとき、内部の仕様を利用者に意識させないよう隠蔽するために使うなどか。

利用時には、class, struct, enumに@DynamicMemberLookUpマークをつけると同時に、subscript(dynamicMember: )というメソッドを作ることで、DynamicMemberLookUpへの適合が完了する。

[Swift]Dynamic Member LookupからSwift5.1で追加されたKeyPath Member Lookupまで

PreferenceKey

子ビューから親ビューへ情報を上げていく仕組みについてBindingを紹介したが、妙な振る舞いになることがあるので複雑なユースケースでは代わりにPreferenceKeyという仕組みを利用する。

SwiftOnTap - PreferenceKey

  • 以下、引用
ContentView.swift
struct ContentView: View {
    var body: some View {
        VStack {
            Color.clear
                .preference(key: StringPreferenceKey.self, value: "Rectangle1")

            Color.clear
                .preference(key: StringPreferenceKey.self, value: "Rectangle2")

            Color.clear
                .preference(key: StringPreferenceKey.self, value: "Rectangle3")

            Color.clear
                .preference(key: StringPreferenceKey.self, value: "Rectangle4")
        }
        .onPreferenceChange(StringPreferenceKey.self) { value in
            print("onPreferenceChange() value: \(value)")
        }
    }
}

struct StringPreferenceKey: PreferenceKey {
    typealias Value = String
    static var defaultValue: Value = "Default"

    static func reduce(value: inout Value, nextValue: () -> Value) {
        let next = nextValue()
        print("reduce() value: \(value), nextValue: \(next)")
        value = next
    }
}

このビューを画面に表示したときコンソールには以下のように出力されます
reduce() value: Rectangle1, nextValue: Rectangle2
reduce() value: Rectangle2, nextValue: Rectangle3
reduce() value: Rectangle3, nextValue: Rectangle4
onPreferenceChange() value: Rectangle4

ローカライズ

String Catalogというファイルを用いて、各言語での文字列のローカライズを行うことが推奨されている。(2023年より)
Localizing and varying text with a string catalog

Evolution of Localization in Swift: From Strings to String Catalogs

UI部品

ボタン

かなり頻繁に使う部品と言える。

イニシャライザのlabelプロパティから外観を変更しても良い。が、同じような見た目のボタンを使い回すなら.buttonStyleモディファイアに自分で作ったカスタムボタンスタイルを渡すと便利である。

Test.swift
struct ContentView: View {
    var body: some View {
        Button(action: {
            print("Hello!")
        }) {
            Text("FooBar")
        }
        .buttonStyle(MyButtonStyle())
    }
}


struct MyButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .padding()
            .background(.green)
            .cornerRadius(4)
            .padding()
    }
}

ButtonStyle

ラベル

アイコンと文字列からなるUI部品である。

そのまま使っても良いが、同じモディファイアをつけたラベルを様々なところで使い回すような場合、.labelStyleモディファイアを使うと良い。クロージャの中で、LabelStyleConfigurationという引数が用意されている。Configurationの中のicontitle に様々なモディファイアをつけておくことで、そのスタイルをあちこちで使い回すことができ、可読性が上がる。

labelStyle(_:)
LabelStyleConfiguration

公式使用例:

apple/sample-food-truck

アラート

.swift
            if #available(iOS 15.0 *) {
                self.view
                    .alert(
                        "Logout",
                        isPresented: isPresented,
                        actions: {},
                        message: { 
                            let msg = "Logout"
                            Text(msg) 
                        }
                    )
            } else {
                self.view
                    .alert(isPresented: isPresented) {
                        Alert(
                            title: Text("Logout"),
                            dismissButton: .default(Text("OK"))
                        )
                    }
            }

プログレスバー・ローディング表示

.swift
ProgressView()

List

上記ドキュメントによれば以下の形式がある。

  • 静的に子を配置していくタイプ
  • Identifiableに準拠したデータにより動的に子を生成するタイプ
  • Identifiableに準拠していないデータにより動的に子を生成するタイプ(Identifiableの代わりに、KeyPassを用いる)
  • ファイルシステムのように階層構造を持ったリストを生成するタイプ
  • 各子部品に編集動作を指定できるタイプ

プルトゥリフレッシュ操作をさせたい場合は、refreshableモディファイアを使えば良い。

ウェブ上にある画像をダウンロードして表示したい。

AsyncImageが使える。(iOS15以上から。)

Example.swift
AsyncImage(
    url: utl
) { image in
    image
        .resizable()
        .scaledToFit()
        .frame(height: 50)
} placeholder: {
    ProgressView()
}

何らかの計算などを伴う複雑なレイアウト

大抵、GeometryReaderを用いると解決できる。該当ビューの親ビューのサイズやフレームを教えてくれる。GeometryReaderが一番外側にある場合は画面サイズも取得できる。

SwiftUIの肝となるGeometryReaderについて理解を深める

親ビューの中のある特定の子ビューが想定よりも小さく潰れて表示されてしまっている。

GeometryReaderを使う、.frame()モディファイアを使うなどが考えられるが、.layoutPriority()モディファイアでも簡単に解決する場合があるので選択肢に入れておきたい。これは、特定のViewにDouble型の引数を指定することで、そのViewの大きさの優先度を設定するようなものである。

layoutPriority(_:)
How to control layout priority using layoutPriority()
【Swift】SwiftUIのListでダイナミックな高さを実現する方法

ScrollViewで、検索バーのような部分はスクロールしても画面上部に固定されるようにしたい。

子ビューにおいてGeometryReaderを使い、親ビューのframeを監視し、特定の領域までスクロールされた場合は、特定の部品を.offsetモディファイアで固定するなどが考えられる。

MainScreen.swift
import SwiftUI

struct MainScreen: View {

    @State var searchQuery: String = ""

    let headerHeight: CGFloat = 30
    let searchBarHeight: CGFloat = 44

    @State var startOffset: CGFloat = 0
    // 現在のスクロール位置。
    @State var offset: CGFloat = 0

    private func safeAreaInsetsTop() -> CGFloat {
        guard let window = UIApplication.shared.windows.first(where: \.isKeyWindow) else { return 0.0 }
        return window.safeAreaInsets.top
    }

    private func getYOffset() -> CGFloat {
        // searchbarが下にスクロールされているケース: 特にsearchbarをずらす必要はない。
        guard offset < 0 else { return 0 }

        // searchbarが上にスクロールされているケース:
        return offset <= -headerHeight ?
            -(offset + headerHeight) :// searchbarが画面外に出てしまうケース:画面外に出ない位置で固定する。
            0// searchbarがまだ画面内にあるケース:まだ画面内にあるので特にsearchbarをずらす必要はない。
    }

    var body: some View {
        ScrollView {
            ZStack(alignment: .top) {
                contents// スクロールビューの中身
                    .padding(.top, headerHeight + searchBarHeight + 10)

                // ヘッダ類
                VStack(spacing: 0) {
                    Text("Some Header")
                        .frame(width: .infinity, height: headerHeight)
                    searchbar
                }
                .frame(width: .infinity)

            }
            .overlay(

                // ScrollViewの中身(ZStack)が上下にどの程度スクロールされているのかを監視。
                GeometryReader { proxy -> Color in
                    let minY = proxy.frame(in: .global).minY
                    if startOffset == 0 {
                        startOffset = offset
                    }

                    // 現在のスクロール位置を記録
                    offset = minY - startOffset
                    print(offset)

                    return Color.clear
                }
                .frame(width: 0, height: 0)

                , alignment: .top
            )

        }
        .clipped()
    }

    private var contents: some View {
        VStack {
            Text("offset: \(offset) safeAreaInsetsTop: \(safeAreaInsetsTop())")
            ForEach(0..<100) { num in
                Text("Number \(num)")
            }
        }
    }

    private var searchbar: some View {
        TextField("Search", text: $searchQuery)// 検索バー
            .frame(width: .infinity, height: searchBarHeight)
            .background(Color.white)
            .offset(y: getYOffset())
    }
}

struct MainScreen_Previews: PreviewProvider {
    static var previews: some View {
        MainScreen()
    }
}

以下の動画も参考になる。
SwiftUI 2.0 Custom ScrollView With Sticky Top Search Bar - Navigation Search Bar - SwiftUI Tutorials

地図

MapKitにてMapが用意されており、SwiftUIで使える。地図上に現在地を表示したり、マークや経路図などさまざまなアイテムを貼り付けられる。

典型的には、Mapのクロージャの中にMarkerを入れ、該当地点にマーカーをつける、およびMapのイニシャライザに渡すposition変数により地図の位置を操作するといった処理をよく行う。

.mapStyle()モディファイアで衛星写真かイラスト描画かを変更できる。

ヘルプ

ユーザーのために操作のヒントなどを表示するためのUI部品。

TipKitがあり、SwiftUIの中でTipViewを使うことができる。(iOS17以上)吹き出し状にテキスト、画像を出すことができる。

visionOS, macOSにおいてはポインタを重ねる(or 視線を合わせる)だけでヘルプマークが出るが、これはhelpモディファイヤが使える。

詳細な実装方針については

Human Interface Guideline - Offering help
SwiftUIでTipKitを使用してユーザーにヒントを表示(iOS 17、WWDC 2023)

を参照

画像などをSNSや他アプリにシェアしたい

ShareLinkを用いる。

タブバー

  • TabView

  • 以下、引用

  • タブのタップ時にアニメーションをさせたい場合 (onChange):

ContentView.swift
import SwiftUI

struct ContentView: View {

    @State private var selectionType: SelectionType = .circle
    /// アニメーション実行用フラグ
    @State private var shouldStartAnimation = false

    var body: some View {
        TabView(selection: $selectionType) {
            ForEach(SelectionType.allCases) { type in
                Image(systemName: type.rawValue)
                    .font(.system(size: 300))
                    .tag(type)
                    .tabItem {
                        Image(systemName: type.rawValue)
                    }
                // アニメーションフラグによってscaleを変更
                    .scaleEffect(shouldStartAnimation ? 1.5 : 1.0)
            }
        }
        .onChange(of: selectionType) { _ in

            // アニメーションを実行
            withAnimation {
                shouldStartAnimation = true
            }

            // 0.5秒後にアニメーションを終了
            Timer.scheduledTimer(withTimeInterval: 0.5,
                                 repeats: false) { _ in

                withAnimation {
                    shouldStartAnimation = false
                }
            }
        }
    }
}
  • 選択中のタブをクリックしても処理を実行する(onReceive)
ContentView.swift
struct ContentView: View {

    @StateObject private var viewModel = ViewModel()

    var body: some View {
        TabView(selection: $viewModel.selectionType) {
            ForEach(SelectionType.allCases) { type in
                Image(systemName: type.rawValue)
                    .font(.system(size: 300))
                    .tag(type)
                    .tabItem {
                        Image(systemName: type.rawValue)
                    }
                    .scaleEffect(viewModel.scaleEffectValue)
            }
        }
        .onReceive(viewModel.$selectionType) { _ in
            viewModel.startAnimation()
        }
    }
}
ViewModel.swift
class ViewModel: ObservableObject {
    @Published var selectionType: SelectionType = .circle
    @Published var shouldStartAnimation = false

    var scaleEffectValue: CGFloat {
        return shouldStartAnimation ? 1.5 : 1.0
    }

    func startAnimation() {
        withAnimation {
            shouldStartAnimation = true
        }

        Timer.scheduledTimer(withTimeInterval: 0.5,
                             repeats: false) { _ in

            withAnimation {
                self.shouldStartAnimation = false
            }
        }
    }
}

ツールバー

toolbar(content:)モディファイアを使うと、ツールバーを設置できる(ナビゲーションバーの上、キーボードの上等)

【SwiftUI】toolbarの使い方!アイテムにボタンを増やす方法

何らかのボタンはもちろん、いわゆるタイトルも設定できる。

Example.swift
        .toolbar {
            ToolbarItem(placement: .principal) {
                Text(L10n.MyScreen.title)
            }
        }

ColectionViewライクなView

iOS13以下

ScrollViewの中にVStackやHStackを入れる。

iOS14以上

ScrollViewの中にLazyVGridやLazyHGridを入れる。パフォーマンス面でこちらの方が良い。

The SwiftUI Equivalents to UICollectionView
SwiftUIでLazyなGridとStackの使い方を学ぶよ

LazyVGridなどの中にZStackを入れるとスクロールビューのコンテンツがが画面外(横方向)にはみ出してしまうことがあった。.frame(width: UIScreen.main.bounds.width)として、ScrollViewの中身のVStackの横の長さを端末サイズに制限するとうまくいった。

LazyVGrid, LazyHGrid を使うときはGridItemを使うことになる。GridItemのサイズには三種類あり、Fixed, Flexible, Adaptiveである。Fixedはプログラムで指定したGridItemの個数を絶対に守り、スクリーンのサイズが足りなくてもそれをはみ出す。Flexibleは可能な限りプログラムで指定したGridItemの個数を守ろうとするが、スクリーンサイズが足りない場合はスクリーンサイズに合わせて個数を減らす。Adaptiveは、プログラムで指定したGridItemの個数を一切守らず、スクリーン幅に照らしてちょうどいいサイズにまで広がる。Adaptiveの場合、GridItemを何個追加しようと無意味であるため、GridItemは一個書けば良いこととなる。
参考: SwiftUI Grid: fixed vs flexible vs adaptive

画像をいじる

Imageクラス。

test.swift
            Image(uiImage: image)
                .resizable()
                .hueRotation(Angle(degrees: viewStore.hue * 360))
                .saturation(viewStore.saturation)
                .brightness(viewStore.brightness)
                .contrast(viewStore.contrast)
  • huerotation()
    • 色相を変換する。色相とは円で表される色のレパートリーであって、引数として角度(Angle)を指定し、色相の円をどの程度移動するか決められる。
  • saturation()
    • 色の彩度、0.0~1.0を指定。
  • brightness()
    • 明度。0.0~1.0を指定。
  • contrast()
    • コントラスト。-1.0~1.0を指定。

マテリアル

背景色として色を指定する代わりに、マテリアルを指定して、不透明感のあるエフェクトをかけて背景を見にくくするようなエフェクトを出すことができる。

特にVisionOSでは背景に色を指定するのは推奨されていない。周囲の環境の明るさの変化に対応するため、マテリアルを代わりに使用することが推奨されている。

直接初期化するのではなく、ultraThin, thin, regular, thick, ultraThick, bar といった定義済みのものを利用する。

Materials
SwiftUI - Material

影をつける

.shadowモディファイアで簡単につけることができる。

もし特定の方向(上下左右)のみ影を出したい時は、mask(alignment:_:)モディファイアで特定の方向にマスクをかけ、マスクがかかっていない方向にのみ影を見せるということになるだろう。

SwiftUI - How to show shadow only on top side?

Identifiable

ListyやForEachに渡すデータは通常Identifiableに適合している必要がある。

TestData.swift
let id = UUID()

以上のようにUUIDを設ければ、適合できる。

子ビューのサイズを知りたい場合

デフォルトで手軽にはできないので、自分で拡張するのをおすすめ。以下は一例です。

View+Extensions.swift
    func sizeReader(size: Binding<CGSize>) -> some View {
        background(
            GeometryReader { proxy -> Color in
                DispatchQueue.main.async {
                    size.wrappedValue = proxy.size
                }
                return Color.clear
            }
        )
    }
ScreenA.swift
struct: ScreenA: View {
    @State private var childViewSize: CGSize = .zero

    var body: some View {
        VStack(spacing: 0) {
            OtherView()
            ChildView()
                .sizeReader(size: $childViewSize)
        }        
        .frame(height: childViewSize.height)
        .padding(20)
    }
}

参考: SwiftUI - Get size of child?

ジェスチャー

gesture(_:including:)モディファイアを利用すると良い。

How to add a gesture recognizer to a view

タップ、ロングプレスの場合は専用のモディファイアもあるので、それでも良い。

.frameなどでViewの大きさを制限しているにも関わらずTextの文字などのコンテンツがViewの大きさをはみ出してしまう。

.clippedモディファイアを使うと良い。

test.swift
ScrollView {
    ZStack {
    // 何か
    }
}
.clipped()

セーフエリアにもコンテンツを表示したい。

edgesIgnoringSafeArea(_:)というのもあったが、deprecatedになったようなので、ignoresSafeArea(_:edges:)を使おう。

Viewの表示位置を本来の位置からずらしたい。

指定したx, yの分だけ本来の位置から横、縦にずれる。

親Viewの座標系の中で(x, y)の位置にViewを移動する。

以下が参考になる。

カメラ・写真の利用

カメラロールの写真について、iOS16以降はSwiftUI準拠のPhotosPickerが利用できる。

カメラ機能については、SwiftUI純正で使えるものがない。UIKitのUIImagePickerControllerをUIViewControllerRepresentableでラップして使うしかない。

参考: 【SwiftUI】カメラを使いたい

ナビゲーションバー

iOS15まで

NavigationViewを用います。この中にNavigationLinkを入れることで、次のページへのリンクを設定できます。

iOS16以降

NavigationStackの中にNavigationLinkを入れましょう。詳細なパターンについてはいろいろあります。

SwiftUI4, iOS16以降で使用できる NavigationStackについて軽く調査した

共通

  • 画面遷移についてはUIKitを使用する例もあります。
  • SwiftUIも最近は使いやすくなりました。iOS16以降の、NavigationStackと、NavigationLinkを用いる形態です。ベタ書きすると以下のようになります。
        NavigationStack {
           Text("hoge")
            .toolbar {
                ToolbarItem(
                    placement: .topBarTrailing) {
                        NavigationLink {
                            SecondView()
                        } label: {
                            Text("SecondView")
                        }
                    }
            }
  • 大量の画面遷移の場合はPathを独自に定義し、NavigationStack, NavigationLinkで用いる形態をお勧めします: NavigationStackを一番活かす人(SwiftUI)

  • タイトル はnavigationTitle(_:)モディファイアで設定します。そのままでは、画面左上にタイトルが大きく表示されます。従来のようにナビゲーションバーの中心部に小さめに表示したい場合は、navigationBarTitleDisplayMode(_:).inlineを指定しましょう。

  • ナビゲーションバー上のボタンについてはtoolbar(content:)を使いましょう。

  • 以下、引用

ContentView.swift
struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack {
                Text("要素1")
                Text("要素2")
            } .navigationTitle("タイトル")
                .toolbar {
                    ToolbarItem(placement: .navigationBarLeading) {
                        Button(action: {
                            print("設定ボタンです")
                        }) {
                            Image(systemName: "gearshape.fill")
                        }
                    }
                    ToolbarItem(placement: .navigationBarTrailing) {
                        Button(action: {
                            print("マイページです")
                        }){
                            HStack {
                                Image(systemName: "person.fill")
                            }
                        }
                    }
                }
        }
    }
}

スプリットビュー

NavigationSplitViewが利用できる。端末の横方向が広いiPadでは画面左側に目次を出し、画面中央に詳細画面を出すことができる。横方向が狭いiPhoneでは、普通のナビゲーションバーのルートと別画面への遷移になるため、iPhoneで使っても安心。

test.swift
        NavigationSplitView {
            List(selection: $selectedContentsType) {
                Foo
            }
            .navigationTitle("Contents")
            .toolbar {
                yourToolbarItems
            }
        } detail: {
                if let selectedContentsType {
                    selectedContentsType.detailView
                } else {
                    Text("Please selecta something")
                }
            }
        }

ケーススタディ

あるViewの背景に別のViewを設定したい

background(alignment:content:)モディファイアを使うと設定できる。あるいは、あるViewそのものよりも大きい背景を作りたい場合は、ZStack
も利用できる。もし画面全体を背景で覆いたいなどであればZStackがすぐできる選択肢となる。safeareaを無視させたいときはZStackに.ignoresSafeArea()モディファイアをつければ良い。
Adding a background to your view

backgroundモディファイアに関して、alignmentとしてcenter, leading, trailingなどが設定可能。背景に設定したい複数のViewに別々のalignmentを設定したいときは、backgroundモディファイアを2回以上使えばいい。あるいはbackgroundモディファイアの中に ZStackを設けることも考えられる。
background(alignment:content:)

VStack, HStack などを幅いっぱいに表示させたい。

VStack, HStackの子としてSpacer()を使うことができます。Spacerは親のVstackまたはHstackが画面一杯になるまで膨らみます。どのように膨らむかは下記ドキュメントを参照。
例えば、HStackの左側にSpacerを置いたときは、HStackが可能な限り最大の横幅に拡大し、右端にコンテンツが並び、左側には空白が広がる。
HStackの右側にSpacerを置いたときは、HStackが可能な限り最大の横幅に拡大し、左端にコンテンツが並び、右側には空白が広がる。
HStackの左右の端にSpacerを1個ずつ置いたときは、HStackが可能な限り最大の横幅に拡大し、中央にコンテンツが並び、左右に同じ大きさの空白が広がる。

Spacer
SwiftUIのSpacerの使い方

VStack, HStackの子をVStack、HStackの中でいっぱいになるまで広げたい

子に.frame(maxWidth: .infinity)モディファイアを使うと可能。

SwiftUI HStack fill whole width with equal spacing

VStack, HStackの中では、代わりにlayoutPriorityをつける方法でも動作した:

.layoutPriority(1)

Stack Over Flow - In SwiftUI, how do you get a view to take as much space as possible?

Listのセレクションタップが動作しない。

色々ググりまくったがうまく原因がわからなかったので、ListではなくLazyVStackの中で
ForEachを使うようにした。ForEachの中の要素にonTapGestureモディファイアを使えばListのセレクションタップのようなことができる。

この場合LazyVStackの直下にForEachを置く必要がある。他の何か(VStackとか)の下にForEachをおくと、ForEachの要素が全て毎回再描画されるので、LazyVStackを使っている意味がなくなってしまう。
LazyVStackを使用すると描画処理が何度も走ってしまう

またUICollectionViewをUIViewRepresentableでラップして使ってもいいと思う。

Listの上下左右に何故か最初から余白が存在する。

理由はよくわからないが、とりあえずマイナスのpaddingを自分でつければ解消する。

HomeView.swift
List()
   .padding(.horizontal, -20)
   .padding(.vertical, -42)

List の見た目の種類について

listStyle(_:)モディファイアをListにつけることによって何種類かの見た目の変更が可能。

  • 以下、引用

スクリーンショット 2023-04-02 17.21.44.png

スクリーンショット 2023-04-02 17.27.20.png
スクリーンショット 2023-04-02 17.27.14.png

Listの背景色を灰色から他に変えたい。

.scrollContentBackground(.hidden)というモディファイアを使い、デフォルトの背景を隠してから色を変える必要あり。

Home.swift
List() {
// ...
}
    .background(Asset.white1.swiftUIColor)
    .scrollContentBackground(.hidden)

How to change SwiftUI list background color

ユーザーによりテキストを入力させたい

主に1行のテキストフィールドの場合はTextField, TextFieldと似ているがパスワードなど機密情報を入力させる場合はSecureField, 何行もの入力をさせる場合はスクロール可能なTextEditorを選ぶと良いだろう。

Apple Developer - Text input and output

TextEditor にプレースホルダーを表示させたい。

デフォルトではやる方法がない。TextEditorのテキストを監視し、空文字の時は、TextEditorとZStackや.overlayで重ねたTextにプレースホルダーを表示させるなどが考えられる。

Stack Over Flow - How to add placeholder text to TextEditor in SwiftUI?
Developers io - 【SwiftUI】プレースホルダー付きのTextEditorを自作してみた

TextFieldで、キーボードのreturnキーが押されたことを検知したい。

onSubmitモディファイアを使う。

TextFieldで、横幅サイズいっぱいまで文字が入力されたら、自動で改行し縦に長くなっていくようにしたい。

TextFieldを初期化する際、axis引数にverticalを指定する。

TextFieldで、ユーザーによる編集を禁止したい。

.disabled(true)モディファイアを使えば可能である。

例:

test.swift
 TextField(
     text: Binding<String>(
         get: { presenter.state.model.translatedText ?? "" },
         set: { _, _ in }// read only
     ),
     axis: .vertical,
     label: {}
 )
    .disabled(true)

Viewの角を丸くしたい。

.cornerRadiusモディファイアを使う。

または,
.backgroundなどで色をつけたりした後、.clipShape(Capsule()),.clipShape(RoundedRectangle(cornerRadius: 50)), .clipShape(Circle())などを使う。

test.swift
Text("Test")
    .background(Color.white)
    .clipShape(Circle())

Viewの特定の角だけ丸くしたい。

ただ.cornerRadiusモディファイアを使うだけでは、四つの角全てが丸くなってしまう。色々やりようはあるが、.cornerRadiusを使う前に丸くさせたくない方向にマイナスの値で.paddingモディファイアを使っておき、.cornerRadiusを使った後にプラスの値で.paddingモディファイアを使って角丸に戻しておくというような簡単なテクニックがおすすめ。

HomeView.swift
    @ViewBuilder
    var dummyInputArea: some View {
        VStack {
            HStack {
                Button(action: {
                    presenter.tapEventSubject.send(.tappedInputButton)
                }) {
                    Text(L10n.homeDummyInputButton)
                        .font(.largeTitle)
                        .foregroundColor(Color.gray)
                }
                    .frame(height: 60)
                Spacer()
                Button(action: {
                    presenter.tapEventSubject.send(.tappedHandWritingInputButton)
                }) {
                    Image(systemName: "pencil.and.outline")
                        .foregroundStyle(.black)
                }
                .frame(width: 44, height: 44)
            }
                .frame(height: 66)
            Spacer()
                .frame(maxHeight: .infinity)
        }
        .padding(.horizontal, 20)
        .background(.white)
        .padding(.top, 40.0)
        .cornerRadius(40.0)
        .padding(.top, -40.0)
        .background(Color(uiColor: UIColor(hex: "#EDEBEBFF")!))
    }

これで、Viewの下側だけ角を丸くしている。

截屏2023-07-28 15.56.38.png

参考: Stack Over Flow - Round Specific Corners SwiftUI

または、Shapeプロトコルを継承するクラスを自分で作成し、.clipShapeモディファイアを使うという手もある。こちらも使いやすい。

  • 以下、引用
View+RoundedCorner.swift
import SwiftUI

struct RoundedCorner: Shape {
    var radius: CGFloat = .infinity
    var corners: UIRectCorner = .allCorners
    
    func path(in rect: CGRect) -> Path {
        let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
        return Path(path.cgPath)
    }
}

extension View {
    func roundedCorner(_ radius: CGFloat, corners: UIRectCorner) -> some View {
        clipShape(RoundedCorner(radius: radius, corners: corners) )
    }
}

Textのフォントを変えたい

こちらに一蘭がまとまっている。

【SwiftUI】フォント(font)の指定方法を総まとめ!

Font構造体はプログラマーが直接初期化することはなく、staticな定数を経由したりしてFont構造体のオブジェクトを得ることが多いようだ。

Divider を横線にしたいのに、縦線になってしまう。

DividerをHStackの中に入れている場合は、minor axisすなわち縦軸に沿って線が引かれるので、縦線になってしまう。横線にしたいのであればVStackの中に入れる。

test.swift
HStack {
    Divider()
}

// ↓

HStack {
    VStack {
        Divider()
    }
}

Dividerの色を変えたいのに変えられない

.foregroundColorモディファイアではなく、.backgroundモディファイアを使うと色を変えられる。

test.swift
Divider()
    .foregroundColor(Color.white)// Meaningless


Divider()
    .background(Color.white) // Great!

Image(systemName: )で作ったSF symbolの画像について、シンボルの部分の大きさを変えたい。

.font()モディファイアで変えられる。

HomeView.swift
Image(systemName: "mic")
    .font(.largeTitle)

How do I set the size of a SF Symbol in SwiftUI?

.onTapGesture()で、タップがSpacer()で作った隙間の領域では反応しない。

.contentShape(_:eoFill:)モディファイアを使い、タップ領域を変更することで対応できる。

SwiftUI can't tap in Spacer of HStack

キーボードが開いているか否かを知りたい。

NotificationCenterを用いて知ることができます。SwiftUIのクラスでキャッチしたい場合はCombineを用いて通知を受けることができます。

参考:

How to detect if keyboard is present in swiftui

Viewを回転させる。

rotationEffect(_:anchor:)を、3D空間で回転させたいならrotation3DEffect(_:axis:anchor:anchorZ:perspective:)を用いる。visionOSにおいては代わりにperspectiveRotationEffect(_:axis:anchor:anchorZ:perspective:)も使える。

ForEach 初期化でCannot convert value of type .. to expected argument type 'Binding'というエラーが出る。

ForEach初期化の部分で出ているが、実際のエラーの原因はそこではなく、ForEachのクロージャ内の各要素のViewを記載するところで、実際には存在しないはずの変数名の変数を利用してしまっていた(要はタイポ)ためにこのエラーが出ていた。

参考: SwiftUI: ForEachでCannot convert value of type .. to expected argument type 'Binding'と出るケース

View出現時・消失時に何らかの処理をさせたい。

  • onAppear
  • task: iOS15以上でしか使えない。asyncのメソッドをawaitをつけることなく非同期で実行できる。該当Viewが消えたり変更されたりした場合は処理がキャンセルされる。
  • onDissappear

これらが使える。

使い分けについては:
https://developer.apple.com/documentation/swiftui/view/ondisappear(perform:)

Modifying state during view update, this will cause undefined behavior.

bodyなどViewの描画中にViewのStateを変更するような処理を直接書くと、このエラーが出て、予測不能な振る舞いになる可能性がある。

ViewのStateの変更は、ジェスチャー発動時や、View出現時など、あくまでViewの描画とは異なるタイミングに記述するべきである。

本件では、RealitiyViewのupdateクロージャはViewのStateが変更されたことをきっかけに呼ばれるのだが、その中でさらにViewのStateをコードで変更してしまっており、この警告が出た。

参考 How to fix “Modifying state during view update, this will cause undefined behavior”

UI部品が多すぎて覚えきれない

Xcodeで「Command + Shift + L」または右上の➕ボタンを押下で、UI部品カタログが表示され、ワンクリックでコード上に追加できる。

Invalid frame dimension

.frame(width: .infinity)

などとしていた時に発生した。こうではなく、代わりに以下のように設定する。

.frame(maxWidth: .infinity)

参考: SOLVED: Invalid frame dimension

共通要素遷移(遷移前の画面の部品が、遷移後の画面の部品にシームレスに変化していくようなアニメーションを伴う画面遷移)をしたい

iOS18以降では、navigationTransition(_:), NavigationTransition.zoom, matchedTransitionSource(id:flag_in:configuration:)を利用することで比較的容易に実現できる。

Using the zoom navigation transition in SwiftUI

テキストフィールドにおいて、キーボードの改行ボタンの文言を変えたい。

.submitLabelモディファイアを使うことで可能となる。

[SwiftUI]SubmitLabelの種類ごとの日本語訳まとめ

SFSafariを表示したい。

SFSafariViewControllerをUIViewControllerRepresentableでラップするしかない。

SafariView.swift
import SwiftUI
import SafariServices

struct SafariView: UIViewControllerRepresentable {
    let url: URL
    
    func makeUIViewController(context: Context) -> some UIViewController {
        return SFSafariViewController(url: url)
    }
    
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
        // Do nothing
    }
}

#Preview {
    SafariView(url: URL(fileURLWithPath: ""))
}

参考書籍

  • たった2日でマスターできるiPhoneアプリ開発集中講座 Xcode15/iOS17/Swift5.9対応, 藤治仁ほか, ソシム, 2023
22
24
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?