📱

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

2021/03/09に公開
1

先日、Clubhouse用の画像加工アプリを開発してリリースしました。
‎「ステータスアイコンエディタ for クラブハウス」をApp Storeで

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

環境

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

開発規模

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

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

移行方針

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

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

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

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

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

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

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

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

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

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

参考

ios - SwiftUI: How to get continuous updates from Slider - Stack Overflow

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

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

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 側でエラーの結果やエラーメッセージを扱うことが出来ます。

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

TextField のreturnKeyType が指定できない

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

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

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

UIKit を使った場面

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

PHPicker

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

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 を使っている所と、completionresults: [PHPickerResult]) をSwiftUI で受け取っている所がポイントです。

    @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 というライブラリを使わせて頂きました。

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()
    }
}
.background(EmptyView().sheet(isPresented: $showingWebView) {
    InAppWebView(urlStr: "Your URL")
})

AdMob

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

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

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

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

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

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

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

.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

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

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

.background(EmptyView().fullScreenCover(isPresented: $showingDoneView) {
    EditDoneView(image: $imageViewModel.resultImage)
        .background(BackgroundCleanerView())
})
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

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

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

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

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

=> コメントを投稿しました https://zenn.dev/link/comments/217950983d2bdb

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

SwiftUI のList の仕様かバグと思われます。
これは.onTapGesture を使うことで回避できます。
ただし、タップ時に見た目を変更する処理を別途実装する必要が出てきます。

    Rectangle()
        .foregroundColor(.white)
        .frame(width: 200, height: 32)
        .onTapGesture {
          didTapButton()

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

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

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

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

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

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

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

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

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

.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

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

image.png

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

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 を実行すれば問題なくシートが提示できました。

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 というでコードを使わせて頂きました。

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()
            }
        }
    }
}
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

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

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

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

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

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

ObservableObject とDispatchQueue

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

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 とでは色の型が異なります。
UIColorColor は相互変換できます。イニシャライザの外部引数名はそれぞれ省略されています。

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

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

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

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

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

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

@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種類のアニメーションを持たせることが出来ました。

@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 で処理を遅らせてから画面を閉じる処理を実行するようにしましたが、これはUIViewanimatecompletion みたいな書き方が出来なかったためです。
AnimatableModifier を使って独自に実装すればcompletion 的な書き方が出来るようなのですが、機会があれば調べてみようと思います。

所感

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

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

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

Discussion

Yusuke AriyoshiYusuke Ariyoshi

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

transaction.disablesAnimations = true を利用することで、SwiftUI のトランジションアニメーションを無効化出来るようである。
無効化してから、独自にアニメーションを実装すればよいのでは

参考

追記

記述の順番が問題だったようである。
sheet が先の場合はアニメーションが表示される。

.background(EmptyView().sheet(isPresented: $showingB) { 
  //
 })
.background(EmptyView().fullScreenCover(isPresented: $showingA) {
  // full screen content
})

fullScreenCover を先にするとアニメーションが無くなり、意図した挙動になった。

.background(EmptyView().fullScreenCover(isPresented: $showingA) {
  // full screen content
})
.background(EmptyView().sheet(isPresented: $showingB) { 
  //
 })