UIKitベースからSwiftUIベースへの移行方法

# はじめに
UIKitで作ったアプリをSwiftUIベースに移行しました。
移行方法を簡単にまとめようと思います。

# 環境
Xcode 15.1

##### サポートバージョン
iOS 17.0以上

# 内容
実際にリリースしているこちらのアプリで作業します。

https://apps.apple.com/jp/app/padelovers/id1538873089

ざっくりとした画面構成はこちら
主に、`Navigation`と`TabBar`部分の切り替えについて書いていきます。

```mermaid
flowchart TB
  起動 --> 1[Navigation]
  1 --> 2[メニュー画面]
  2 --> A[画面A] & B[画面B]　& C[画面C] & D[画面D ...]
  A --> 3[TabBar]
  3 --> A1[画面A-1] & A2[画面A-2] & A3[画面A-3]
  C --> C1[画面C-1]
```

Navigaton(メニュー)→画面A→TabBar

<img width="100" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/44813a99-23d3-3424-d4c5-9720a16b5bdf.png"><img width="100" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/6c493e2d-ecdc-392a-a04b-8fd455aa8940.png"><img width="100" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/868cf65b-c236-fda2-954b-7d90c0aa7fc7.png">



## 下準備
まずはUIKitベースからSwiftUIベースに切り替えられるよう起動画面動線を変更します。

### `AppDelegate.swift`を修正

`@UIApplicationMain`のアノテーションを削除します。

```diff_swift
- @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
...
```

### 初期表示画面を追加

SwiftUIで初期表示するUIを作っておきます。

```swift: PadeLoversApp
import SwiftUI

@main
struct PadeLoversApp: App {

    @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
    
    var body: some Scene {
        WindowGroup {
            HomeView()
        }
    }
}
```

```swift: HomeView
import SwiftUI

struct HomeView: View {
    var body: some View {
        Text("Hello, World!")
    }
}

#Preview {
    HomeView()
}
```

`@UIApplicationDelegateAdaptor`を実装することでこれまで通り`AppDelegate`の処理が起動時に呼ばれるようになります。

### info.plistの修正

[`Application Scene Manifest`](https://developer.apple.com/documentation/bundleresources/information_property_list/uiapplicationscenemanifest)の以下３箇所を修正するとSwiftUIの画面が表示されるようになります。

- [`Enable Multiple Windows`](https://developer.apple.com/documentation/bundleresources/information_property_list/uiapplicationscenemanifest/uiapplicationsupportsmultiplescenes) を YESに変更
- [`Storyboard Name`](https://developer.apple.com/documentation/bundleresources/information_property_list/uiapplicationscenemanifest/uisceneconfigurations/uiwindowscenesessionroleapplication/uiscenestoryboardfile)を削除
- [`Main storyboard file base name`](https://developer.apple.com/documentation/bundleresources/information_property_list/nsmainstoryboardfile) を削除

<img width="400" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/ea997859-4ca2-c3ed-fe96-4d5b3efeaf11.png">

↓

<img width="400" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/3b68a33a-5a8a-6b1b-a4a9-0b8684455c27.png">


最低限の処理のみ記載しているので、その他不要な部分はお掃除する必要があります。

ここまでの作業でアプリを起動すると既存画面は表示されず、SwiftUIのViewが表示されます。

## 各画面のViewControllerを`UIViewControllerRepresentable`でラップする

まずは各画面をSwiftUIベースの実装で使えるように`UIViewControllerRepresentable`で実装します。

https://developer.apple.com/documentation/swiftui/uiviewcontrollerrepresentable

画面を生成するだけの場合は単純にラップしただけです。

```swift
struct RandomNumberViewRepresentable: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UIViewController {
        let storyboard = UIStoryboard(name: "RandomNumber", bundle: nil)
        let viewController = storyboard.instantiateViewController(identifier: "RandomNumber") as! RandomNumberViewController
        return viewController
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) { }
}
```

Delegateの処理が含まれている場合は親画面に値を渡せるようにしておきました。

```swift
struct StartGameViewRepresentable: UIViewControllerRepresentable {
    @Binding var groupID: String?
    @Binding var padelID: UUID?

    func makeUIViewController(context: Context) -> UIViewController {
        let storyboard = UIStoryboard(name: "StartGame", bundle: nil)
        let viewController = storyboard.instantiateViewController(identifier: "StartGame") as! StartGameTableViewController
        viewController.delegate = context.coordinator
        return viewController
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) { }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, StartGameTableViewControllerDelegate {
        var viewController: StartGameViewRepresentable

        init(_ viewController: StartGameViewRepresentable) {
            self.viewController = viewController
        }

        func callBackFromStartGameModalVC(groupID: String?, padelID: UUID?) {
            if let groupID {
                viewController.groupID = groupID
            }
            if let padelID {
                viewController.padelID = padelID
            }
        }
    }
}
```

このまま表示させるとUIKitでNavigationBarに表示させていたボタンなどが表示されません。

必要に応じて`toolbar`を使ってNavigationBarにボタンを配置、クリック時の処理も追加します。

```swift
struct RandomNumberView: View {
    @State private var showPopover = false

    var body: some View {
        VStack {
            RandomNumberTableViewRepresentable()
        }
        .toolbar {
            ToolbarItem(placement: .navigationBarTrailing) {
                Button(action: {
                    showPopover.toggle()
                }, label: {
                    Image(systemName: "gearshape")
                })
            }
        }
        .popover(isPresented: $showPopover) {
            VStack {
                SettingViewRepresentable()
            }
        }
    }
}
```

この辺りの実装が大変な場合は、SwiftUIで実装し直す方が早いこともありそうですね。。。

## SwiftUIのNavigationStackを使う

※ `NavigationStack`はiOS16以上で使用できます

https://developer.apple.com/documentation/swiftui/navigationstack

まずは、Home画面に`NavigationStack`を追加します。

NavigationStackに渡すpathを渡すため、enumで各画面の値を定義しておきます。


```swift
struct HomeView: View {
    @State private var path: [Screen] = []

    enum Screen: Hashable {
        case gameSetting
        case gameStart
        case randomNumber
        case mainSetting
        case gameStartDefault(groupID: String? = nil, padelID: UUID? = nil)
        case gameStartMix(groupID: String)
        ...
    }

    var body: some View {
        NavigationStack(path: $path) {
            VStack {
                HStack(spacing: 20) {
                    NavigationLink(value: Screen.gameSetting) {
                        Image("btn_game_setting")
                            .resizable()
                            .frame(width: 100, height: 100)
                            .padding(20)
                    }
                    ...
                }
            }
            .navigationDestination(for: Screen.self) { screen in
                switch screen {
                case .gameSetting:
                    CommonDataView()
                case .gameStart:
                    StartGameView(path: $path)
                case .randomNumber:
                    RandomNumberTableView()
                case .mainSetting:
                    MainSettingView()
                case let .gameStartDefault(groupID, padelID):
                    DefaultGameTabView(path: $path, groupID: groupID, padelID: padelID)
                case .gameStartMix(let groupID):
                    MixGameTabView(path: $path, viewModel: MixGameViewModel(groupID: groupID))
                }
            }
        }
    }
}
```

画面遷移をさせる方法は
`NavigationLink(value:)`で値を渡す、もしくは`path`に直接追加する形でも画面遷移することができます。

画面の表示は`.navigationDestination(for: Screen.self)`で受け取る形で実現できます。

移行方法については以前もまとめたことがありました。

https://qiita.com/yoshitaka/items/319e5d18273ae2541dd1

## SwiftUIのTabViewを使う

https://developer.apple.com/documentation/swiftui/tabview

タブの定義と`selection`の値を追加、`TabView(selection: $selection)`を使ってTabViewを実装します。

```swift
struct DefaultGameTabView: View {
    @State private var selection: Screen = .gameSetting
    var groupID: String?
    var padelID: UUID?

    enum Screen {
        case gameSetting, gameData, gameResult

        var title: String {
            switch self {
            case .gameSetting:
                "試合設定"
            case .gameData:
                "試合組合せ"
            case .gameResult:
                "試合結果"
            }
        }
    }

    var body: some View {
        TabView(selection: $selection) {
            GameViewSettingRepresentable(groupID: groupID, padelID: padelID)
                .tabItem {
                    Image(systemName: "person.crop.rectangle.stack")
                    Text("試合設定")
                }
                .tag(Screen.gameSetting)
            GameDataRepresentable()
                .tabItem {
                    Image(systemName: "person.3")
                    Text("試合組合せ")
                }
                .tag(Screen.gameData)
            GameResultRepresentable()
                .tabItem {
                    Image(systemName: "doc.text.magnifyingglass")
                    Text("試合結果")
                }
                .tag(Screen.gameResult)
        }
        .navigationBarBackButtonHidden(true)
        .navigationTitle(selection.title)
        .toolbar {
            ToolbarItem(placement: .topBarTrailing) {
                switch selection {
                case .gameResult:
                    Button(action: {
                        showAlert = true
                    }, label: {
                        Image(systemName: "house.fill")
                    })
                default:
                    Spacer()
                }
            }
        }
    }
}
```

`.navigationTitle(selection.title)`の部分では、タブを切り替えるごとにNavigationBarのタイトルが変わるようにしました。

また、NavigationBarに配置したボタンについても、タブの条件で表示の出しわけをしています。

# おわりに
重い腰を上げて取り組んでみましたが、規模が小さく画面数も少ないからかそこまで大変ではなかった印象です。
サポートバージョンも一気に上げてみたので（個人アプリということもあり）iOS17以上で使える機能もどんどん取り入れていこうと思います。

# 参考

https://dev.classmethod.jp/articles/first-step-uikit-to-swiftui/

https://zenn.dev/usk2000/articles/6bab1522bcc65f

https://qiita.com/snoozelag/items/fe95df7e748ad5fc3205