はじめに
ある日 Apple から「アップデートしなさすぎだから今月中にアップデートしなかったらアプリ消すわ」という連絡が来ました。すっかり忘れていて無事消されちゃいました。
とくに新機能も思いつかないし、ただバージョン上げるのもなと思い UIKit から SwiftUI へ移行することにしました。
そのときつまずいたポイントについてです。
開発環境
下記に修正しました。
- iOS 14 -> iOS 16(最小ターゲット)
- Xcode 11.3 -> Xcode 14.3
対象のアプリ
対象のアプリはこちら(Mac Catalyst で Mac 版も作成しました)。
入力画面 | 履歴画面 |
---|---|
主要な機能は下記です。
- 上に入力した文字を塩基配列にして下に表示する(どちらもスクロール可)
- 変換結果を履歴に 10 件表示する
- 音声入力ができる(Mac は不可)
- クリップボードに変換結果のコピーができる
- 変換結果を他アプリに共有できる
- 変換結果をテキストファイルにしてダウンロードできる
- 変換結果をツイートできる
つまずいたポイント
SwiftUI は毎年ちょっとさわってはいるのですが毎回忘れているので私の SwiftUI の理解度としてはどんな View があるかはなんとなく知っているけど遷移処理とかはどうするんだったっけ?くらいのレベルです。
2 画面しかないから余裕と思ってたら意外とつまずきました。主につまずいたのは下記です。
- SFSpeechRecognizerDelegate の指定
- UIActivityViewController の表示
- UIDocumentPickerViewController の表示
- キーボードのボタン追加
- アプリ起動時の処理
- トースト表示
SFSpeechRecognizerDelegate の指定
もともと SFSpeechRecognizerDelegate
を下記のように viewDidLoad
で指定していました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
override func viewDidLoad() { speechRecognizer?.delegate = self SFSpeechRecognizer.requestAuthorization { authStatus in OperationQueue.main.addOperation { switch authStatus { case .authorized: self.recordButton.isEnabled = true case .denied, .restricted, .notDetermined: self.recordButton.isEnabled = false @unknown default: fatalError("error") } } } } extension DNAConverterViewController: SFSpeechRecognizerDelegate { func speechRecognizer(_ speechRecognizer: SFSpeechRecognizer, availabilityDidChange available: Bool) { if available { recordButton.isEnabled = true } else { recordButton.isEnabled = false } } } |
SwiftUI の場合 View が struct なのでデリゲートが設定できません。
悩んだ末 SFSpeechRecognizer
を別のクラスに持たせてそこにクロージャをつけて View の onAppear
で設定するようにしました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
// Viewの処理 .onAppear { speechManager.requestAuthorization { available in recordable = available } } final class SpeechManager: NSObject { private var speechRecognizer: SFSpeechRecognizer? private var authorizationHandler: ((Bool) -> Void)? func requestAuthorization(completion: ((Bool) -> Void)?) { authorizationHandler = completion SFSpeechRecognizer.requestAuthorization { authStatus in OperationQueue.main.addOperation { switch authStatus { case .authorized: completion?(true) case .denied, .restricted, .notDetermined: completion?(false) @unknown default: assertionFailure("unknown authStatus") completion?(false) } } } } } extension SpeechManager: SFSpeechRecognizerDelegate { func speechRecognizer(_ speechRecognizer: SFSpeechRecognizer, availabilityDidChange available: Bool) { authorizationHandler?(available) } } |
onAppear
は何度も呼ばれる気もしますが今回のケースならまあいいかなと。
UIActivityViewController の表示
UIActivityViewController
を表示するには遷移元の ViewController が必要です。View から表示したい場合どうするんだ?と思いましたが iOS 16 からは ShareLink というものがあるようです。こちらを使うことで解決しました。iPad と Mac の場合はちゃんとポップオーバー表示に切り替えてくれるようです👏
参考:【SwiftUI】iOS16.0 以降で使える ShareLinkでできることを調べてみた
UIDocumentPickerViewController の表示
こちらも UIActivityViewController
同様に遷移元の ViewController が必要です。こちらは SwiftUI でそれっぽいものがなく UIViewControllerRepresentable
を使用しなければいけないんだろうなという感じですがデリゲートはどうするんだ?というところでつまずきました。
Coordinator
というのがあるんだよと教えてもらったのでそれで解決しました。下記の記事の「更にSwiftUIらしく」のコードを UIDocumentPickerViewController
に書き換えて使用しました。
キーボードのボタン追加
もともとは下記のように UITextView
に設定してキーボードの上にボタンを表示していました。
1 2 3 4 5 6 7 8 |
let toolBar = UIToolbar() let button = UIBarButtonItem(title: NSLocalizedString("convert", comment: ""), style: .done, target: self, action: #selector(convert(_:))) let space = UIBarButtonItem.init(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) toolBar.setItems([space, button], animated: false) toolBar.sizeToFit() originalTextView.inputAccessoryView = toolBar |
これはたしか無理だったな諦めよと思ったのですが iOS 15 から下記でいけるみたいです!
1 2 3 4 5 6 7 8 9 10 11 12 |
TextField( "ほげ", text: $originalText, ) .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer() Button("ぼたん") { print("ぼたん!!") } } } |
参考:InputAccessoryView with SwiftUI
アプリ起動時の処理
アプリ起動時にアプリストアのバージョンと比較して古ければアラートを表示する下記の処理がありました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// SceneDelegate func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let _ = (scene as? UIWindowScene) else { return } let appStoreManager = AppStoreManager() appStoreManager.checkVersion { [weak self] result in DispatchQueue.main.async { if case .success(.shouldUpdate) = result { self?.window?.rootViewController?.showAlert(message: NSLocalizedString("update_message", comment: ""), buttonAction: { UIApplication.shared.open(appStoreManager.appStoreURL) }) } } } } |
SceneDelegate
消したしどこに書けばいいんだ?と悩んだのですが最終的に下記のように App
の初期化時に記載しました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
@main struct DNAConverterApp: App { init() { let appStoreManager = AppStoreManager() appStoreManager.checkVersion { result in DispatchQueue.main.async { if case .success(.shouldUpdate) = result { if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let root = windowScene.windows.first?.rootViewController { root.showAlert(message: NSLocalizedString("update_message", comment: ""), buttonAction: { UIApplication.shared.open(appStoreManager.appStoreURL) }) } } } } } var body: some Scene { WindowGroup { ContentView() } } } |
とりあえず動いたのでまあいいでしょう。
トースト表示
もともと添付の before のようになにか処理が終わったときに画面下部にトースト表示をしていました。
一画面でしか使わないし ZStack
使えばいけるかな?と思ったのですがいい感じに下からにゅっと出す方法がわからない。そもそも画面下部に表示する方法がわからない。なんか真ん中に表示されてるしもういっそ表示を変えてしまおうと添付の after のように変更することで対応しました。
before | after |
---|---|
実装としてはこんな感じです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
if isToastShown { ToastView(message: $toastMessage) .opacity(toastOpacity) } @State private var toastMessage = "" @State private var isToastShown = false @State private var toastOpacity: CGFloat = 0.0 private func showToast(message: String) { toastMessage = message isToastShown = true let duration: TimeInterval = 0.5 withAnimation(.linear(duration: duration)) { toastOpacity = 1.0 } DispatchQueue.main.asyncAfter(deadline: .now() + duration + 1.0) { hideToast() } } private func hideToast() { let duration: TimeInterval = 1.0 withAnimation(.linear(duration: duration)) { toastOpacity = 0.0 } DispatchQueue.main.asyncAfter(deadline: .now() + duration) { isToastShown = false } } |
ここはもうもとの表示は断念しました。
おまけ
リリース時につまずいた箇所のおまけです(SwiftUI 関係ないです)。
ビルドバージョン
ストアにアップロードしたところ輸出コンプライアンスの警告が出ていたので info.plist にキー追加してアップし直しました。まだ提出してないしビルドバージョン同じでいいかな?と思って変えずにアップしたところストア側で勝手に修正されるようでした。
どちらも 1.1.0 でアップしたのですが 2 回目にアップした方は 2.0 になっていました。とくに問題ないはずですが気持ち悪かったので 1.1.1 にしてアップしなおしました。
CFBundleVersion is empty
2 回目にストアへアップロードしようとしたところ突然下記のエラーが出るようになりました。
CFBundleVersion is empty but must be composed of one to three period-separated integers.
1 回目はアップできたし、バージョンも数値でちゃんと入力されている。なんでだ?とわりと悩んだのですが Git でコミットログを確認したところ輸出コンプライアンス関連のキーを追加したときになぜか CFBundleVersion が消えてしまっていたようでした。
消えたやつを追加したら無事なおりました。Git 管理していてよかった。
おわりに
わりと色々つまずいたのですがコミュニティの times でぐちぐちつぶやきながらやってたら優しい方々がアドバイスくれたのでリリースするところまでいけました!ありがとうございます!
レイアウト関連もわりとつまずきましたがてきとーにググるとそれっぽい表示にはなりそこまで困らなかった印象です。移行してていいなと思ったのが TextField まわりでした。複数行入力と FocusState がよかったです。
SwiftUI
は毎年 API が変わっている印象なのでなかなか追いかけるのがむずかしいですね。
参考サイト
以下は SwiftUI への移行で参考になったサイトです。
- UIKitからSwiftUIへの移行の第一歩を踏み出してみた
- 【SwiftUI】iOS 15からの@FocusStateを使用して画面タップでキーボードを閉じる方法
- 【SwiftUI】iOS16.0 以降で使える ShareLinkでできることを調べてみた
- SwiftUIで端末の画像を表示する
- SwiftUIにおける余白の適切な実装パターン
コメント