背景
SwiftUIでアプリを作っていると同じmodifierを複数回記述するパターンに遭遇する。問題設定としては
- modifierの引数は同じ型で違う値
- クロージャーは同じ
これを簡潔に書きたい。
enum MyError: Error, Equatable {
case network
case badRequest
case notFound
}
// ある画面で3つのアクションがあり、それぞれからエラーが帰る可能性がある
struct ContentView: View {
@State var error1: MyError?
@State var error2: MyError?
@State var error3: MyError?
var body: some View {
Text("app")
.onChange(of: error1, perform: onError)
.onChange(of: error2, perform: onError)
.onChange(of: error3, perform: onError)
}
func onError(error: MyError?) {
guard let error else { return }
/// ...
}
}
また、(要件にもよるが) onChange(of: error1 ?? error2 ?? error3)
のような手法だと、例えばerror1
とerror2
がほぼ同時に発生した時に片方しか呼ばれないので不適とする。
struct ContentView: View {
@State var error1: MyError?
@State var error2: MyError?
@State var error3: MyError?
var body: some View {
Text("app")
.onChange(of: error1 ?? error2 ?? error3, perform: onError)
}
}
解決策: forEach
あるmodifierを配列の要素分繰り返すmodifier。AnyViewを使っているので多少オーバヘッドはありそうだが、意味的には記事冒頭のコードと等価のはず。
extension View {
func eraseToAnyView() -> AnyView {
AnyView(self)
}
func forEach<T, ModifiedView: View>(_ elements: [T], modify: (AnyView, T) -> ModifiedView) -> some View {
elements.reduce(self.eraseToAnyView()) { view, element in
modify(view, element).eraseToAnyView()
}
}
}
struct ContentView: View {
@State var error1: MyError?
@State var error2: MyError?
@State var error3: MyError?
var body: some View {
Text("app")
.forEach([error1, error2, error3]) { view, element in
view.onChange(of: element, perform: onError)
}
}
}
おまけ
実際onChangeが頻出パターンなのでonChange専用のmodifierを作るのが丸いかもしれない。(onChange以外で使えそうなケースがあったら教えてください)
extension View {
func onChange<T: Equatable>(each elements: [T], perform: @escaping (T) -> Void) -> some View {
self.forEach(elements) { view, element in
view.onChange(of: element) { element in
perform(element)
}
}
}
}
struct ContentView: View {
@State var error1: MyError?
@State var error2: MyError?
@State var error3: MyError?
var body: some View {
Text("app")
.onChange(each: [error1, error2, error3], perform: onError)
}
}
そしてonChange(each:)
を作るだけなら配列にonChangeを行うアプローチでもいけそう。ただし使っているonChangeがiOS17+。
(その他の違いはパフォーマンスだけだと思うが、どちらのパフォーマンスが良いかはわからない)
extension View {
func onChange<T: Equatable>(each elements: [T], perform: @escaping (T) -> Void) -> some View {
self.onChange(of: elements) { before, after in
after.changedElements(from: before).forEach(perform)
}
}
}
private extension Array where Element: Equatable {
func changedElements(from before: [Element]) -> [Element] {
zip(before, self).filter(!=).map(\.1)
}
}