SwiftUIでStoryboardベースのアプリを書き直してみた

先日、Clubhouse用の画像加工アプリを開発してリリースしました。
[‎「ステータスアイコンエディタ for クラブハウス」をApp Storeで](https://apps.apple.com/jp/app/status-icon-editor/id1552717991)

その際にはStoryboard を利用してアプリを開発したのですが、今後は開発効率を考えるとSwiftUI を使っていくのが良さそうだと感じました。
そこで今回、UI まわりの実装をSwiftUI で置き換えることにしました。

## 環境

- macOS Big Sur: v12.2.2
- Xcode: v12.4
- 開発対象 : iOS 14.0 ~ 14.4

## 開発規模

最初のバージョンを3日で作りました。 企画・デザイン1日、実装2日。
8画面程度の小規模なアプリです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17330/381c4eb5-d38b-8476-e69a-6b141e253bf0.png)


筆者の経験としては、SwiftUI のチュートリアル1周と、SwiftUI・WidgetKit を利用するアプリの開発を行ったことがあります。とはいえSwiftUI を完全に理解したわけではなく、今回も調べながら実装することになりました。
結果として、今回のSwiftUI でのリプレースには2日くらい必要でした。


## 移行方針

次の方針でSwiftUI への移行を進めました。

- UI はSwiftUI で書く
- UIKit にあるがSwiftUI に用意されていない機能については、UIKit ベースのコードをSwiftUI から呼び出す 
  - 実装としては`UIViewRepresentable` や`UIViewControllerRepresentable` を利用する
- プロジェクトの一部分だけ置き換えるのではなく、全ての画面をSwiftUI ベースに移行する




## 実現方法が分からなくて調べたこと

SwiftUI のコンポーネントだけではやりたいことが実装出来ない場面がありました。
個々の問題と、どうやって対処したかについて書きます。

### Slider でユーザーが指を離す前に処理を実行したい

UIKit の`UISlider` にはContinuous Updates の設定があるのですが、SwiftUI の`Slider` にはそれがありません。
Slider に用意されている`onEditingChanged` では、スライダーのつまみを動かしている最中の値の変化を検知することができません。

```swift
Slider(
    value: $yourValue,
    in: 0...100,
    onEditingChanged: { editing in
        isEditing = editing
    }
)
```

つまみを動かしている最中の値を取得したい場合は、以下のように `Binding` とsetter を利用すればOK でした。

```swift
Slider(value: Binding(
        get: { yourValue },
        set: { newValue in
          print(newValue)
        }), in: 0...100, step: 1,
        //
```

なお同様に、`UIPickerView` の`didSelectRow` に相当するものがSwiftUI の`Picker` にありません。`Picker` についても `Binding` を使うことで似たような挙動が実現出来そうです。


#### 参考

[ios - SwiftUI: How to get continuous updates from Slider - Stack Overflow](https://stackoverflow.com/questions/56725084/swiftui-how-to-get-continuous-updates-from-slider)

### フォトライブラリへの画像の書き込み

画像をフォトライブラリへ保存するために、`UIImageWriteToSavedPhotosAlbum` を利用することが出来ます。
ただしこれをSwiftUI で行う場合は、エラーが起こった場合のハンドリングができません。
そこで今回は `ImageSaver` というコードを使わせて頂きました。

```swift
import UIKit

class ImageSaver: NSObject {
    var successHandler: (() -> Void)?
    var errorHandler: ((Error) -> Void)?
    
    func writeToPhotoAlbum(image: UIImage) {
        UIImageWriteToSavedPhotosAlbum(image, self, #selector(saveError), nil)
    }

    @objc func saveError(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) {
        if let error = error {
            errorHandler?(error)
        } else {
            successHandler?()
        }
    }
}
```

`errorHandler` を利用することで、SwiftUI 側でエラーの結果やエラーメッセージを扱うことが出来ます。

```swift
let imageSaver = ImageSaver()
imageSaver.successHandler = {
    print("Success!")
}
imageSaver.errorHandler = {
    print("Oops: \($0.localizedDescription)")
}
imageSaver.writeToPhotoAlbum(image: processedImage)
```

#### 参考

[Saving the filtered image using UIImageWriteToSavedPhotosAlbum() - a free Hacking with iOS: SwiftUI Edition tutorial](https://www.hackingwithswift.com/books/ios-swiftui/saving-the-filtered-image-using-uiimagewritetosavedphotosalbum)


### TextField のreturnKeyType が指定できない

キーボードのリターンキーを"done" に変更したい場面について。
SwiftUI の`TextField` では`.keyboardType` や`.autocapitalization` で設定を変更することができるものの、リターンキーの種類については変更ができません。

今回は暫定対応として[siteline/SwiftUI-Introspect](https://github.com/siteline/SwiftUI-Introspect) というライブラリを利用することで、リターンキーの種類を変更することが出来ました。
現実的には自分でUIKit のラッパーを書くことになりそうです。

```swift
import Introspect
//
    TextField("", text: $text, onCommit: {
        didCommitTextField()
    })
    .introspectTextField { textfield in
        textfield.returnKeyType = .done
    }
```





## UIKit を使った場面

UIKit ベースのコードをSwiftUI から呼び出すことが必要だった場面について書きます。
公式のチュートリアルでも紹介されていますが、`UIViewRepresentable` や`UIViewControllerRepresentable` を利用することで、UIKit とSwiftUI のブリッジを作成して、UIKit のコードを使えるようにします。

### PHPicker 

写真ピッカーについて。
iOS14 で、`ImagePickerController` の代わりとして使える`PHPicker` API が追加されました。`PHPickerViewController` を使うとiOS が画像選択のUI を表示してくれるのですが、これをSwiftUI から利用するためのAPI を見つけることができませんでした。
そこで対策として、SwiftUI とUIKit のブリッジを作成しました。

```swift
import SwiftUI
import PhotosUI

public typealias PhotoPickerViewCompletionHandler = (([PHPickerResult]) -> Void)

struct PhotoPickerView: UIViewControllerRepresentable {
    let configuration: PHPickerConfiguration
    let completion: PhotoPickerViewCompletionHandler
    
    @Binding var isPresented: Bool
    
    func makeUIViewController(context: Context) -> PHPickerViewController {
        let controller = PHPickerViewController(configuration: configuration)
        controller.delegate = context.coordinator
        return controller
    }
    
    func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: PHPickerViewControllerDelegate {
        private let parent: PhotoPickerView
        
        init(_ parent: PhotoPickerView) {
            self.parent = parent
        }
        
        func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
            parent.isPresented = false
            parent.completion(results)
        }
    }
}
```

以下はSwift UI 側のコードです。
`.sheet` を使っている所と、`completion` で`results: [PHPickerResult])` をSwiftUI で受け取っている所がポイントです。


```swift
    @State var showingPhotoPicker = false

    var phptoPickerConfiguration: PHPickerConfiguration {
        var config = PHPickerConfiguration(photoLibrary: .shared())
        config.filter = .images
        config.selectionLimit = 1
        return config
    }
    
    var body: some View {
        NavigationView {
        //
        .background(EmptyView().sheet(isPresented: $showingPhotoPicker) {
            PhotoPickerView(configuration: phptoPickerConfiguration, completion: { results in
                guard let result = results.first else { return }
                let itemProvider = result.itemProvider
                if itemProvider.canLoadObject(ofClass: UIImage.self) {
                    itemProvider.loadObject(ofClass: UIImage.self) { uiImage, error in
                        if let uiImage = uiImage as? UIImage {
                            DispatchQueue.main.async {
                                image = uiImage
                            }
                        }
                        if let error = error {
                            print("\(error.localizedDescription)")
                        }
                    }
                }
            }, isPresented: $showingPhotoPicker)
        })
````


### アプリ内WebView

UIKit でいう`WKWebView` に相当するものがSwiftUI にまだ無いようです。

[kylehickinson/SwiftUI-WebView](https://github.com/kylehickinson/SwiftUI-WebView) というライブラリを使わせて頂きました。


```swift
import SwiftUI
import WebView

struct InAppWebView: View {
    @Environment(\.presentationMode) var presentationMode
    
    @StateObject var webViewStore = WebViewStore()
    
    var urlStr: String!
    
    func didTapCloseButton() {
        presentationMode.wrappedValue.dismiss()
    }
    
    var body: some View {
        NavigationView {
            WebView(webView: webViewStore.webView)
                .navigationBarTitle(Text(verbatim: webViewStore.title ?? ""), displayMode: .inline)
                .navigationBarItems(leading:
                                        HStack {
                                            Button(action: didTapCloseButton) {
                                                Image(systemName: "xmark")
                                                    .imageScale(.large)
                                                    .aspectRatio(contentMode: .fit)
                                                    .frame(width: 32, height: 32)
                                            }
                                        },trailing:
                                            HStack {
                                                Button(action: goBack) {
                                                    Image(systemName: "chevron.left")
                                                        .imageScale(.large)
                                                        .aspectRatio(contentMode: .fit)
                                                        .frame(width: 32, height: 32)
                                                }.disabled(!webViewStore.canGoBack)
                                                Button(action: goForward) {
                                                    Image(systemName: "chevron.right")
                                                        .imageScale(.large)
                                                        .aspectRatio(contentMode: .fit)
                                                        .frame(width: 32, height: 32)
                                                }.disabled(!webViewStore.canGoForward)
                                            }
                )
                .ignoresSafeArea(.all)            
        }
        .onAppear {
            webViewStore.webView.load(URLRequest(url: URL(string: urlStr)!))
        }
    }
    
    func goBack() {
        webViewStore.webView.goBack()
    }
    
    func goForward() {
        webViewStore.webView.goForward()
    }
}
```

```swift
.background(EmptyView().sheet(isPresented: $showingWebView) {
    InAppWebView(urlStr: "Your URL")
})
```

### AdMob

バナー広告やインタースティシャル広告の読み込みと表示をするためにも、UIKit と連携するコードが必要です。
長くなるので割愛します。



## 挙動がおかしい？と思ったこと

自分のSwiftUI の仕様の理解が浅いためか、直感的に実装が出来なかった場面がありました。
UIKit やクロスプラットフォームでの開発 (React Native) では出くわさなかった問題のため、ここでメモしておきます。


### 1つのView にアラートやモーダルを複数定義した場合の動作がおかしい

`.alert`, .`sheet`, `.fullScreenCover` についてです。
以下のように一つのView に対して複数の要素を定義すると、最後の要素しか扱われません。

```swift
.alert(isPresented: $showingFirstAlert) {
    // 表示されない
    Alert(title: Text("1"), message: Text(""))
}
.alert(isPresented: $showingSecondAlert) {
    Alert(title: Text("2"), message: Text(""))
}
```

いくつか解決方法があるようです。
今回は`.background(EmptyView())` を使うことで回避できました。
推奨されたやり方が他にあるかもしれません。

```swift
.background(EmptyView()).alert(isPresented: $showingFirstAlert) {
    // 表示される
    Alert(title: Text("1"), message: Text(""))
})
.background(EmptyView()).alert(isPresented: $showingSecondAlert) {
    Alert(title: Text("2"), message: Text(""))
})
```


#### 参考

[swift - SwiftUI: Support multiple modals - Stack Overflow](https://stackoverflow.com/questions/57103800/swiftui-support-multiple-modals)




### .fullScreenCover で背景が透明にならない

SwiftUI で全画面を覆うモーダル画面を表示したい場合には `.fullScreenCover` を使います。
ただし、そのまま使うと背景の`opacity` が効きません。
今回は`BackgroundCleanerView` というコードを利用させて頂きました。

```swift
.background(EmptyView().fullScreenCover(isPresented: $showingDoneView) {
    EditDoneView(image: $imageViewModel.resultImage)
        .background(BackgroundCleanerView())
})
```


```swift
struct BackgroundCleanerView: UIViewRepresentable {
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        DispatchQueue.main.async {
            view.superview?.superview?.backgroundColor = .clear
        }
        return view
    }

    func updateUIView(_ uiView: UIView, context: Context) {}
}
```

### 参考
[ios - Is there a way to set a fullScreenCover background opacity? - Stack Overflow](https://stackoverflow.com/questions/65295388/is-there-a-way-to-set-a-fullscreencover-background-opacity/65850543)


### .fullScreenCover でトランジションスタイルが指定できない

UIKit のview controller presentation ではtransition style を設定することができます。しかしSwiftUI の`.fullScreenCover` にはトランジションの設定が存在せず、下から出てくるトランジションしかありません。

今回は全画面モーダルかつcross disolve のようなトランジションを実装したかったのですが、厳しそうだったので対応を見送りました。

モーダル関連に関しては、SwiftUI のAPI を使わずにUIKit のコードで実装をすれば、やりたい事が実現できるかもしれません。



### List 内にButton があると、Button の外をタップしてもイベントが実行されてしまう


SwiftUI の`List` の仕様かバグと思われます。
これは`.onTapGesture` を使うことで回避できます。
ただし、タップ時に見た目を変更する処理を別途実装する必要が出てきます。
 
```swift
    Rectangle()
        .foregroundColor(.white)
        .frame(width: 200, height: 32)
        .onTapGesture {
          didTapButton()
```


### NavigationLink を使うとButton タップ時の反転がおかしい


`NavigationLink` の中に`Button` を設置すると、要素をタップした際のハイライトが、半透明ではなく完全に透明になってしまうようです。
`ZStack` を利用して、NavigationLink  とButton を並列に配置することで回避できました。

```swift
NavigationView {
    NavigationLink(destination: NextView(sourceImage: image), isActive: $isActiveNextView) {
        Button(action: { didTapButton() }, label: {
```

```swift
NavigationView {
    ZStack {
        NavigationLink(destination: NextView(sourceImage: image), isActive: $isActiveNextView) {
            EmptyView()
        }
        Button(action: { didTapButton() }, label: {
```



### TextEditor のプレースホルダ表示を独自実装すると、タップ判定がおかしい

以下のようなコードで、テキスト入力欄にプレースホルダの表示を実装しようとしました。

```swift
ZStack {
    TextEditor(text: $text)
    //
    Text("Write here.")
    //
}
```

しかし、2回タップをしないとTextEditor にフォーカスが当たらないという現象が起こりました。

`Text("Write here.").allowsHitTesting(false)` としても結果は同じでした。
今回は解決策が見当たらなかったので、やむなくデザインを変更することにしました。



### border は付くが角丸が付かない

SwiftUI では記述するmodifier の順番にView へのスタイルが適用されます。そこで、意図通りの表示になるように、modifier の順番を修正します。
border を表示するためには`.overlay` を使います。

```swift
.frame(width: geometry.size.width, height: cardHeight - translationMinHeight)
.background(Color.white)
.cornerRadius(24)
.overlay(
    RoundedRectangle(cornerRadius: 24)
        .stroke(Color.black.opacity(0.3), lineWidth: 0.5)
)
```


### ColorPicker のボタンにカラーホイールが表示される

iOS 14 で登場したカラーピッカーですが、なんとSwiftUI からも`ColorPicker` を設置することで利用することができます。
ただし、要素を表示すると規定のカラーホイールが表示されてしまいます。このカラーホイールのアイコンを非表示にする設定は見当たりませんでした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17330/f889a064-d9e1-d174-4295-814deab89ea5.png) 

今回実現したかったUI は、アイコンが無く、ユーザーが選択した色が反映されるボタンです。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17330/66629173-009b-db2c-745e-d4a27880b3b9.png)

OS のバージョンによって挙動が変わりそうな予感がしていておすすめし辛いのですが、`.scaleEffect` や`.clipped()` を利用して、アイコンを見えないようにすることができました。


```swift
ColorPicker("", selection: $color)
    .onChange(of: color, perform: { value in
      print(color)
    })    
    .labelsHidden()
    .scaleEffect(CGSize(width: 999, height: 999))
    .frame(width: 44, height: 44)
    .cornerRadius(8)
    .clipped()
    .overlay(
        RoundedRectangle(cornerRadius: 8)
            .stroke(Color.black.opacity(0.3), lineWidth: 0.5)
    )
````

### .fullScreenCover で扱うView からUIActivityViewController を提示しようとすると失敗する

コンテンツを共有する機能のために、iOS のシェアシートを呼びたい時があります。`UIActivityViewController` を利用するコードでこれが実現できます。
しかし`.fullScreenCover` で扱うView から `rootViewController.present` を実行すると、シートの提示が失敗しました。
この場合は`rootVC.presentedViewController!.present` に変更するなどして、正しいViewController で `present` を実行すれば問題なくシートが提示できました。

```swift
let activityVC = UIActivityViewController(activityItems: objectsToShare, applicationActivities: nil)
activityVC.completionWithItemsHandler = {(activityType, completed, returnedItems, error) in
    //
}
guard let rootVC = UIApplication.shared.windows.first?.rootViewController else { return }
guard let presentingVC = rootVC.presentedViewController else { return }
presentingVC.present(activityVC, animated: true, completion: nil)
```


### Xcode のPreview が表示されない

Xcode 上の表示についてです。
Preview が上手く表示されない場合は、SwiftUI View の記述でシンタックスが間違っている場合があります。SwiftUI ではない部分のコードと比べて、警告の位置や内容が参考にならないことが起こりやすいと感じました。
まずcmd + b でビルドが通ることを確認し、SwiftUI View のコードを分割するなり、少しずつコメントアウトしていくなりすると誤りが発見しやすくなります。


## その他メモ

次回の開発の際に参照するかもしれない、忘備録です。

### ColorPicker の.onChange 実行時などの処理を間引きたい

今回のアプリには重い処理があります。ユーザーからの入力に対して、その重い処理を連続して実行することを避けたい場面がありました。
SwiftUI に限らず使えるテクニックですが、処理を間引くために`debounce` というでコードを使わせて頂きました。

```swift
extension DispatchQueue {
    func debounce(delay: DispatchTimeInterval) -> (_ action: @escaping () -> ()) -> () {
        var lastFireTime = DispatchTime.now()
        return { [weak self, delay] action in
            let deadline = DispatchTime.now() + delay
            lastFireTime = DispatchTime.now()
            self?.asyncAfter(deadline: deadline) { [delay] in
                let now = DispatchTime.now()
                let when = lastFireTime + delay
                if now < when { return }
                lastFireTime = .now()
                action()
            }
        }
    }
}
```

```swift
private let debounce = DispatchQueue.global().debounce(delay: .milliseconds(20))

var body: some View {
    ColorPicker("", selection: $color)
        .onChange(of: color, perform: { value in
            debounce {
                didChangeTextColor()
              }
        }) 
```


#### 参考
[DispatchQueueでthrottle/debounceを実現する - Qiita](https://qiita.com/marty-suzuki/items/496f211e22cad1f8de19)

### .navigationBarHidden(true) が効く時と効かない時がある

画面遷移時に、遷移先のView で `.onAppear` の中に重い処理があると、ナビゲーションバーを非表示にする処理が画面に反映されないことがありました。

画面遷移が終わった後で重い処理が実行されるように、 `DispatchQueue.global().async` や `DispatchQueue.main.asyncAfter` を使って回避しました。
この場合、遷移先画面の初期表示がいい感じになるように、読み込み中のUI を表示するなどして工夫する必要があります。

この問題はUIKit ベースの開発でも発生するものかもしれません。

```swift
.navigationBarHidden(isNavigationBarHidden)
    .onAppear() {
        isNavigationBarHidden = true
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            heavyTask()
        }
```




### ObservableObject とDispatchQueue

これについてはXcode が警告を出してくれるのですが、`ObservableObject` の更新をSwiftUI View に伝えるために、メインキューで`@Publised` プロパティの操作を行うことが求められます。 

```swift
class ImageViewModel: ObservableObject {
    @Published var resultImage: UIImage?
//
    func createImage(image: UIImage) {
        DispatchQueue.global().async { [unowned self] in
           guard let outputImage = heavyTask(image: image) else { return }
           DispatchQueue.main.async { [unowned self] in
               resultImage = outputImage
           }
        }
    }
```






### 色の型変換

UIKit とSwiftUI とでは色の型が異なります。
`UIColor` と`Color` は相互変換できます。イニシャライザの外部引数名はそれぞれ省略されています。

```swift
let color: Color = Color.white
let uiColor: UIColor = UIColor(color)
```

```swift
let uiColor: UIColor = UIColor.white
let color: Color = Color(uiColor)
```


### ジェスチャーでView の位置を移動させる

SwiftUI でも当然ユーザーインタラクションやアニメーションの実装はできるという話です。

スワイプしたら上げ下げ出来る、巷でハーフモーダルと呼ばれているUI を作りました。移行前のプロジェクトではライブラリを利用していたのですが、今回はSwiftUI で独自に実装しました。

- カード部分をスワイプして上下方向に動かすことが出来る
- 一定より上方向には動かすことができない
- 一定より下方向に動かしてスワイプを完了すると、カードが見えなくなるまで下方向に動き、画面が閉じる


SwiftUI では`@GestureState` と `.offset` と `.gesture(DragGesture()` を使うことで、スワイプに連動して要素の位置を移動させる実装が出来ました。
タップしてから指を離すまでの動きの情報を`.updating` で扱い、指を離した時の情報を`.onEnded` で扱います。


```swift
@GestureState private var translation = CGSize(width: 0, height: 0)
@State private var positionY = CGFloat(0)
// 
    cardContainer
        .offset(y: positionY + translation.height)
        .gesture(DragGesture()
            .updating($translation, body:{ value, state, transaction in
                if value.translation.height < translationMinHeight {
                    return
                }
                state = CGSize(width: value.translation.width, height: value.translation.height)
            })
            .onEnded({ value in
                if translationMaxHeight < value.translation.height {
                    dismiss()
                }
            })
        )
```

要素の移動にアニメーションを付けることもできます。
ここでは`.animation` を使うことで、カードが上下する時の動きが滑らかになりました。

SwiftUI には`.withAnimation` もあり簡潔にアニメーションが書けます。しかしカードをドラッグするアニメーションと、カードを画面外に移動させるアニメーションという2種類のアニメーションを今回実装しようとしたところ、`.onEnded` で`.withAnimation` を使っても、カードをドラッグするアニメーションとの両立ができませんでした。
そこで今回はカード要素に対する`.animation` に渡すアニメーションの種類を切り替えることで、2種類のアニメーションを持たせることが出来ました。


```swift
@State private var isDismissing = Bool(false)
private let dragAnimDuration = TimeInterval(0.1)
private let dismissalAnimDuration = TimeInterval(0.2)
//    
    cardContainer
        .offset(y: positionY + translation.height)
        .gesture(DragGesture()
                .updating($translation, body:{ value, state, transaction in
                    if value.translation.height < translationMinHeight {
                            return
                        }
                        state = CGSize(width: value.translation.width, height: value.translation.height)
                    })
                    .onEnded({ value in
                        if translationMaxHeight < value.translation.height {
                            isDismissing = true
                            positionY = UIScreen.main.bounds.height
                            DispatchQueue.main.asyncAfter(deadline: .now() + dismissalAnimDuration, execute: {
                                dismiss()
                            })
                        }
                    })
        )
        .animation(isDismissing ? .easeOut(duration: dismissalAnimDuration) : .easeOut(duration: dragAnimDuration))
```

`DispatchQueue.main.asyncAfter` で処理を遅らせてから画面を閉じる処理を実行するようにしましたが、これは`UIView` の`animate` の`completion` みたいな書き方が出来なかったためです。
`AnimatableModifier` を使って独自に実装すればcompletion 的な書き方が出来るようなのですが、機会があれば調べてみようと思います。


## 所感

SwiftUI は良いですね。
条件別にプレビューを表示できるので、端末や翻訳や特定のデータの差異ごとにUI の表示がどうなるか、つまり実装がちゃんとできているかを確認することが簡単にできます。
またStoryboard と比べると、コードでUI を記述できることから、実装の移植がかなり楽になると感じました。チーム開発だとコードレビューも楽になりますね。

一方で、UIKit ベースでのアプリ開発と比べると、SwiftUI にはバグっぽい挙動が多いのが気になりました。また、UIKit だと簡単に使えるAPI がSwiftUI では用意されていなくて、結局UIKit のコードを書かないといけない場面はそれなりにありそうです。
現状では、機能の実装やトラブルシューティングのためにUIKit の知識は必要といえるでしょう。

SwiftUI を使うと全体的には生産性が上がりそうに感じました。古いOS のサポートが必要なく、細かい部分も実装し切る自信がある (もしくは実装できなくても妥協できる) のであれば、積極的にSwiftUI を使っていくのがよいのではないでしょうか。