SwiftUIでStoryboardベースのアプリを書き直してみた
先日、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 から呼び出す
- 実装としては
UIViewRepresentable
やUIViewControllerRepresentable
を利用する
- 実装としては
- プロジェクトの一部分だけ置き換えるのではなく、全ての画面を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,
//
なお同様に、UIPickerView
のdidSelectRow
に相当するものが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)
参考
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 から呼び出すことが必要だった場面について書きます。
公式のチュートリアルでも紹介されていますが、UIViewRepresentable
やUIViewControllerRepresentable
を利用することで、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
を使っている所と、completion
でresults: [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 タップ時の反転がおかしい
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
を設置することで利用することができます。
ただし、要素を表示すると規定のカラーホイールが表示されてしまいます。このカラーホイールのアイコンを非表示にする設定は見当たりませんでした。
今回実現したかったUI は、アイコンが無く、ユーザーが選択した色が反映されるボタンです。
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().async
や DispatchQueue.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 とでは色の型が異なります。
UIColor
とColor
は相互変換できます。イニシャライザの外部引数名はそれぞれ省略されています。
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
で処理を遅らせてから画面を閉じる処理を実行するようにしましたが、これはUIView
のanimate
のcompletion
みたいな書き方が出来なかったためです。
AnimatableModifier
を使って独自に実装すればcompletion 的な書き方が出来るようなのですが、機会があれば調べてみようと思います。
所感
SwiftUI は良いですね。
条件別にプレビューを表示できるので、端末や翻訳や特定のデータの差異ごとにUI の表示がどうなるか、つまり実装がちゃんとできているかを確認することが簡単にできます。
またStoryboard と比べると、コードでUI を記述できることから、実装の移植がかなり楽になると感じました。チーム開発だとコードレビューも楽になりますね。
一方で、UIKit ベースでのアプリ開発と比べると、SwiftUI にはバグっぽい挙動が多いのが気になりました。また、UIKit だと簡単に使えるAPI がSwiftUI では用意されていなくて、結局UIKit のコードを書かないといけない場面はそれなりにありそうです。
現状では、機能の実装やトラブルシューティングのためにUIKit の知識は必要といえるでしょう。
SwiftUI を使うと全体的には生産性が上がりそうに感じました。古いOS のサポートが必要なく、細かい部分も実装し切る自信がある (もしくは実装できなくても妥協できる) のであれば、積極的にSwiftUI を使っていくのがよいのではないでしょうか。
Discussion
transaction.disablesAnimations = true
を利用することで、SwiftUI のトランジションアニメーションを無効化出来るようである。無効化してから、独自にアニメーションを実装すればよいのでは
参考
追記
記述の順番が問題だったようである。
sheet が先の場合はアニメーションが表示される。
fullScreenCover を先にするとアニメーションが無くなり、意図した挙動になった。