Effective SwiftUI 候補(仮説)
Zenn スクラップだと目次がない、ディスカッションしにくいという欠点を感じていたので、GitHub Discussion に移行させていただきました。
ある程度時間が経ったら、本スクラップはクローズしたいと思います。
NavigationView は常に利用する側の View で指定する
Why?
NavigationView
の宣言位置は、他コントロールと組み合わせた場合に厳密に求められることが多く、共通的に利用される View 側に定義すると面倒なケースが多い。(単なる経験則)
また、利用される View が NavigationView
を必須としていないのであれば、その View の再利用性が高まる。
struct ContentView: View {
var body: some View {
TabView {
NavigationView { // 🚫 Do not moving inside.
ChildView()
.navigationTitle("Fruits")
.toolbar {
ToolbarItem {
Button(action: {}) {
Image(systemName: "square.and.arrow.up")
}
}
}
}
.tabItem {
Image(systemName: "list.bullet")
}
}
}
}
struct ChildView: View {
var body: some View {
List(["🍎", "🍌", "🍇"], id: \.self) { item in
NavigationLink(destination: Text(item)) {
Text(item)
}
}
}
}
Why not?
NavigationView
の扱いを熟知してしまえば、単なる好みの問題かもしれない。
onAppear
やtask
などで決して行わない
ObservedObject への DI をWhy?
@ObservedObject
の再生成とonAppear
およびtask
の実行タイミングは一致しない。そのため、(Viewごと)ObservedObjectが再生成されて DI が行われない、という不完全な状態を生むことがある(そしてそれは多くの場合、強制アンラップによるクラッシュを誘発する)。
struct ChildView: View {
@Environment(\.authState) var authState: AuthState!
// ⚠️ System might recreate a entire view at any time.
@ObservedObject var observedObject: ChildViewModel = .init()
// ✅ `@StateObject` is safe.
@StateObject var stateObject: ChildViewModel = .init()
var body: some View {
Text("Hello")
.task {
// 🚫 Should be avoided!
observedObject.inject(authState: authState)
// ✅ OK.
stateObject.inject(authState: authState)
}
}
}
Discussion
@StateObject
であれば、Viewごとに1つのインスタンスしか生成しない(再生成されない)ことが保証されるため問題ない[1]。
References
-
Xcode 13.3.0 RC において、macOSアプリで
@StateObject
のインスタンスが複数回生成されるというバグに遭遇している。 ↩︎
.tag
を enum で指定する場合、型付けされた専用のメソッドを用意する
Why?
型安全になり、コード補完の恩恵も受けられるようになる。
fileprivate enum Item {
case all, star
}
fileprivate extension View {
func itemTag(_ tag: Item) -> some View {
self.tag(tag)
}
}
struct ContentView: View {
@State private var selected: Item = .all
var body: some View {
TabView(selection: $selected) {
Text("All")
.tabItem {
Image(systemName: "list.bullet")
}
.itemTag(.all) // ✅ Typesafe and can auto-complete.
.tag(.all) // 🚫 Compile error.
Text("Star")
.tabItem {
Image(systemName: "star.fill")
}
.itemTag(.star)
}
}
}
Why not?
わざわざ extension で専用のメソッドを用意するのは過剰かもしれない。
あるいはループで処理できるのであれば、これをやるメリットはない。
fileprivate enum Item: String, CaseIterable {
case all = "All"
case star = "Star"
}
fileprivate extension Item {
func tabItem() -> some View {
switch self {
case .all:
return Image(systemName: "list.bullet")
case .star:
return Image(systemName: "star.fill")
}
}
func content() -> some View { ... }
}
struct ContentView: View {
@State private var selected: Item = .all
var body: some View {
TabView(selection: $selected) {
ForEach(Item.allCases, id: \.self) { item in
Text(item.content())
.tabItem {
item.tabItem()
}
.tag(item) // ✅ Type-safe is not needed.
}
}
}
}
Discussion
マーカープロトコルなどを用意して、それをもとにコードを自動生成するのはありかもしれない。ただし、今回の例ではコード量は僅かなため、過剰な仕組みとなる可能性は高い。
サブビューを生成する処理は、Computed-property よりもメソッドを好む。
Why?
Computed-property の場合、読み手に View の生成コストが0であると誤解させやすい。また、引数を導入したくなった場合は、結局メソッドで書き直す必要が発生する。
struct ContentView: View {
var body: some View {
Group {
subView1
subView2()
subView3(label: "3")
}
}
// 🚫 Not better.
var subView1: some View {
Text("1")
}
// ✅ Good.
func subView2() -> some View {
Text("2")
}
// ✅ Introducing arguments is also easy.
func subView3(label: String) -> some View {
Text(label)
}
}
Discussion
Computed-propertyの方が()
が無くて読みやすいと感じる人もいるかもしれない。
私が SwiftUI を学習していく過程で、プラクティスとなりそうだと感じた 候補(仮説) を列挙したものです。
十分な経験に基づいているわけではないので、仮説には誤りが含まれていたり、場合によっては逆にアンチパターンであったというケースも考えられますので、その点はご了承ください🙏
ディスカッションについては歓迎しますので、気楽にコメントいただければ幸いです。
なお、本内容を『Effective SwiftUI』という本または記事にするかは未定です。
Twitter 元スレッド:
ViewModel で非同期通信が必要な場合、MainActorで宣言する
Why?
ViewModel 内で async/await を使えるように。オブジェクトの生成は同期的に行いたいケースもあるため、必要ならば Initializer は nonisolated
で宣言する。
struct ContentView: View {
@ObservedObject var object: SharedObject = .init() // ✅ Ease to initialize.
var body: some View {
Text("Hello")
.task {
await object.onAppear()
}
}
}
@MainActor
final class SharedObject: ObservableObject {
nonisolated init() {} // 💡 with `nonisolated` if needed.
func onAppear() async {
// ✅ TODO: some asynchronous process.
}
}
isPresented
とdismiss
を利用する
シートを実装する際は Environment のWhy?
呼び出し側から表示・非表示を制御するBinding<Bool>
を渡す必要がなくなり、コードがシンプルになる。また、表示対象の View がシート表示専用にならないため再利用性も上がる。
struct ContentView: View {
@State var isPresented: Bool = false
var body: some View {
Button("Open sheet") {
isPresented = true
}
.sheet(isPresented: $isPresented) {
SomeView() // ✅ Not need to pass a binding like `$isPresented`.
}
}
}
struct SomeView: View {
// ✅ iOS 15+
@Environment(\.isPresented) var isPresented
@Environment(\.dismiss) var dismiss
// 💡 You can use `PresentationMode` in iOS 13+ (deprecated in iOS 15+)
@Environment(\.presentationMode) var presentationMode
var body: some View {
Text("Hello")
// 💡 This view was shown as sheet.
if isPresented {
Button("Close") {
dismiss() // 💡 Dismiss sheet.
}
}
}
}
References
プレビュー用に空の SwiftUI プロジェクトを用意する
Why?
SwiftUI のプレビューは便利だが、プロジェクトが大きくなるにつれて、ビルド時間の問題に悩まされる。空のプロジェクトに必要なコードをコピペして、そこでプレビューすると作業効率が良い。
Discussion
コピペするコード量(依存)が多いとこれでも手間になるので、プレビューを用意する View 最初から厳選したほうが良いかもしれない。(e.g. ObservableObject
を持たない View)
公式ドキュメントより SwiftOnTap を先に参照する
Why?
Apple の公式ドキュメントはサンプルコードが不足していることが多く、コードの書き方や動作イメージを想像するのが難しいケースが多い。
SwiftOnTap は有志により作成されたドキュメントではあるものの、サンプルコードや動作イメージが充実しており、初心者に優しい API ドキュメントに仕上がっている。
Why not?
最新の正確なAPI仕様については、引き続きAppleの公式ドキュメントを参照すること。
SwiftOnTap はあくまで有志による作成なので、最新のAPI仕様には追従できていないケースもある。また、すべてのコントロールについて十分なサンプルコードが含まれているわけではない。
ただ、ドキュメントは GitHub で管理されており、ドキュメントの追加(Contribution)についても歓迎されているので、不足を感じたら PR を投げることで、より質の高いドキュメントに一歩近づけることができる。
References
[for Beginners] View.body のコンパイルエラーが解消できない場合、小さな関数に移動して試す
Why?
SwiftUI の View は Swift の言語機能(Opaque Result Type、Result Builder など)をフル活用しているため、少しの間違いで難解なエラーメッセージが出力されることがある。
うまくコンパイルできない箇所のコードをコピペして小さな関数で試すことで、何を間違えているのか気づきやすくなる。
@ViewBuilder // ✅ Allow top-level `if` and `switch`.
func v() -> some View {
// 💡 Cut and paste from `View.body`.
if true {
Text("Hello")
} else {
Button(Image(systemName: "pencil")) {
print("hello")
}
}
}
view
スニペットを利用する
[for Beginners] View を作る際は Why?
Viewおよびプレビュー用のコードが自動生成されるため、自分でそれを書く手間を省くことができる。
action
を関数名で指定しない
Button の Why?
関数名(参照)で指定すると宣言的でスッキリするが、デバッグ時にブレークポイントで気軽に止められないという欠点もある。また、async
な関数の場合は(自分で拡張しない限り)そもそもそうした記述はできず、コードスタイルの統一性の観点からもクロージャ形式で指定したほうが良い。
struct ContentView: View {
@ObservedObject var viewModel = ContentViewModel()
var body: some View {
// ⚠️ Can't stop by break-point. (but break when `body` was called)
Button("A", action: viewModel.onTapButton)
// ✅ Can stop by break-point.
Button("B") {
viewModel.onTapButton()
}
// 🚫 Not allowed when `action` is async function.
//Button("C", action: viewModel.onTapButtonAsync)
// ✅ Can stop by break-point.
Button("D") {
Task {
await viewModel.onTapButtonAsync()
}
}
}
}
@MainActor
class ContentViewModel: ObservableObject {
nonisolated init() {}
nonisolated func onTapButton() {
print("Hello!")
}
func onTapButtonAsync() async {
// TODO: some async process
}
}
Discussion
Xcode 13から導入された列ブレークポイントを利用すれば、関数名(参照)箇所についても止めることができる。しかし、行ブレークポイントとしても機能するため、body
関数の評価時にも止まってしまうデメリットも存在する。
なお、コードスタイルについてはチームメンバーの好みなどもあるはずなので、必要に応じて話し合うこと。
[Trade-off] SFSafeSymbols が本当に必要かよく検討する
Why?
OSS として開発されている SFSafeSymbols を利用することで SFSymbol の指定がコンパイルタイムセーフになるが、意図した表示(アイコン)になることが保証されるわけではない。
標準APIは SF Symbols アプリ(ページ下部) からコピペできるというメリットもある。
どちらがより必要なものかよく検討すること。
struct ContentView: View {
var body: some View {
// ✅ Ease to copy from `SF Symbols` app.
// ✅ Readable and highlighted.
// ⚠️ But not compile-time safe.
Image(systemName: "square.and.arrow.up")
// ✅ Compile-time safe.
// 🚫 But need to type it myself.
// 🚫 No guarantee that it will render as expected.
Image(systemSymbol: .squareAndArrowUpFill)
}
}