ObjecTips

Swift & Objective-C で iOS とか macOS とか

Xcode 12 の SwiftUI + Core Data のプロジェクトテンプレートが不完全

新規プロジェクト作成でプロジェクトテンプレートから iOS App を選択

f:id:Koze:20210427063905p:plain:w600

Interface SwiftUI, Life Cycle SwiftUI App, Language Swift を選択して Use Core Data をチェック

f:id:Koze:20210427063950p:plain:w600

作成される初期画面 ContentViewbody とビルド結果は以下

    var body: some View {
        List {
            ForEach(items) { item in
                Text("Item at \(item.timestamp!, formatter: itemFormatter)")
            }
            .onDelete(perform: deleteItems)
        }
        .toolbar {
            #if os(iOS)
            EditButton()
            #endif

            Button(action: addItem) {
                Label("Add Item", systemImage: "plus")
            }
        }
    }

f:id:Koze:20210427075543p:plain:w400

画面が真っ白で何も表示されない。

1つ目の原因は NavigationView の指定が無いこと。toolbar のコンテンツを表示するには NavigationView が必要なのでまず body の中身のルートの ListNavigationView に入れる。

    var body: some View {
        NavigationView {
            List {
                // 省略
            }
            .toolbar {
                // 省略
            }
        }
    }

f:id:Koze:20210427080655p:plain:w400

編集ボタンが表示された。でもまだ項目追加のプラスボタンが表示されていない。

次に toolbar のコンテンツの各ボタンを ToolbarItem に入れる。引数の placement は今回は navigationBarLeading navigationBarTrailing を使用。

    var body: some View {
        NavigationView {
            List {
                // 省略
            }
            .toolbar {
                #if os(iOS)
                ToolbarItem(placement: .navigationBarLeading) {
                    EditButton()
                }
                #endif
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
        }
    }

f:id:Koze:20210427083621p:plain:w400

プラスボタンを押すと後はテンプレートの実装通り動作しリストが表示される。

f:id:Koze:20210427083802p:plain:w400

これから初めて Swift/SwiftUI をやるぞって時にテンプレートがこの有様だとスタートでつまづいてしまうのでヘルプになればと思いシェアです*1

*1:このテンプレートの不具合が気になった人は Feedback Assistant にレポートしてくれると幸い

Xcode 11 で iOS 14 Simulator を使用する

Xcode 12 への移行はまだ先の予定だけど取り敢えず iOS 14 で動作確認・修正作業をしたいなんてケースがある。
そこでタイトルの「Xcode 11 で iOS 14 Simulator を使用する」これができるらしい。

stackoverflow.com

手順

  • Xcode 12 付属の iOS Simulator を起動 *1
    • Xcode 12 を起動して Xcode > Open Developer Tool > Simulator を選択して起動
    • もしくは Xcode 12 内臓の Xcode.app/Contents/Developer/Applications/Simulator.app を起動
  • Simulator.app で File > Open Simulator > iOS 14.0 から任意のデバイスを選択して開く
  • Xcode 12 を起動していたら終了する
  • Xcode 11 を起動する
  • Xcode 11 の Run Destination のリストに起動中の iOS 14 Simulator が表示されて Run できる

メリット

  • Xcode 11(iOS 13 SDKビルド)+ iOS 14 の組み合わせでの動作確認が簡単にできる
  • iOS 14 beta を入れた実機をたくさん用意しなくて良い

Xcode 12 と iOS 14 のリリース後もプロジェクトの完全移行までしばらくの間 Xcode 11(iOS 13 SDK)を使ってビルドを続けるケースはままあるのでこの技はしばらく使えそう。

*1:Xcode 12 の iOS Simulator はドックに入れておくと次から楽

Vision framework VNRecognizeTextRequest でのテキスト認識(OCR)の対応言語 iOS 13, iOS 14

iOS 13でOCRに使えるテキスト認識のAPIが登場。
使えるRevisionは1。iOS 14でRevision 2が登場した。

@available(iOS 13.0, *)
public let VNRecognizeTextRequestRevision1: Int

@available(iOS 14.0, *)
public let VNRecognizeTextRequestRevision2: Int

iOS 13

検証環境 Xcode 12.0 beta (12A6159) iPadOS 14 (17A5301v) 1st beta

以下のコードでサポート言語を確認できる

print(try! VNRecognizeTextRequest.supportedRecognitionLanguages(for: .fast, revision: VNRecognizeTextRequestRevision1))
print(try! VNRecognizeTextRequest.supportedRecognitionLanguages(for: .accurate, revision: VNRecognizeTextRequestRevision1))
["en-US"]
["en-US"]

iOS 13は英語のみの対応

iOS 14

print(try! VNRecognizeTextRequest.supportedRecognitionLanguages(for: .fast, revision: VNRecognizeTextRequestRevision1))
print(try! VNRecognizeTextRequest.supportedRecognitionLanguages(for: .accurate, revision: VNRecognizeTextRequestRevision1))
print(try! VNRecognizeTextRequest.supportedRecognitionLanguages(for: .fast, revision: VNRecognizeTextRequestRevision2))
print(try! VNRecognizeTextRequest.supportedRecognitionLanguages(for: .accurate, revision: VNRecognizeTextRequestRevision2))
["en-US", "fr-FR", "it-IT", "de-DE", "es-ES", "pt-BR"]
["en-US", "fr-FR", "it-IT", "de-DE", "es-ES", "pt-BR", "zh-Hans", "zh-Hant"]

iOS 14では英語、フランス語、イタリア語、ドイツ語、スペイン語、ポルトガル語(ブラジルポルトガル語)、中国語(繁体字と簡体字)の対応が追加された。
中国語(繁体字と簡体字)は処理時間のかかる accurate モードのみでサポートされる。
今回も日本語の対応は含まれていない。
中国語以外で対応されたのはアルファベット文字ベースのみ。日本語に限らずアラビア語のアラビア文字やロシア語のキリル文字などアルファベット文字と字形が大きく異なるものはまだ対応は難しいのかも知れない。*1
とりあえず出たばかりのiOS 14のファーストβで正式リリースまでに変更になる可能性もあるので引き続き注視していく。*2

*1:技術的なものかリソース配分の問題かは分からないけど

*2:日本語対応して欲しい人はFeedback Assistantで報告しよう

ヒラギノフォントが切れる問題 SwiftUI編

検証環境 Xcode 11.4.1

iOS+ヒラギノ+UILabel とか UIButton でググると過去の UIKit での問題が参照できます。
この問題は SwiftUI でも発生します。

まずサンプルとしてヒラギノ角ゴのW3を指定して Text を作成。
(デバッグのため青色の枠線も表示)

Japanese font without Japanese character causes th ...

f:id:Koze:20200509011329p:plain:w536

上の日本語を含まない状態だとpとyの下が切れている。
下の日本語を含んだ状態だと切れずに表示されている。


試しに frame を設定して Text の表示領域を大きくしてみる。*1

    var body: some View {
        VStack(spacing: 10) {
            Text("Copy")
                .font(font)
                .frame(height: 60)
                .border(borderColor)

            Text("Copy")
                .font(font)
                .border(borderColor)
                .frame(height: 60)
                .border(borderColor)
        }
    }

f:id:Koze:20200509013020p:plain:w530

表示領域は大きくなっても文字は切れたまま。


次は TextbaselineOffset を試してみる。*2

    let font = Font.custom("HiraginoSans-W3", size: 50)
    let uiFont = UIFont(name: "HiraginoSans-W3", size: 50)!
    let borderColor = Color(.systemBlue)

    var body: some View {
        HStack(spacing: 10) {
            Text("Copy")
                .font(font)
                .border(borderColor)
            
            Text("Copy")
                .font(font)
                .baselineOffset(-uiFont.descender)
                .border(borderColor)
        }
    }

f:id:Koze:20200509015209p:plain:w520

文字が切れなくなった。


SwiftUI の Font からはフォント情報の descender を取得する事ができないため別途 UIFont を使っている。これを CTFont を使う様にリファクタリング。
UIFontdescender は正の値が入っているが CTFontdescender は負の値が入っているので baselineOffset で使う際の符号も修正している。

    let ctFont = CTFontCreateWithName("HiraginoSans-W3" as CFString, 50, nil)
    var ctFontDescender: CGFloat {
        CTFontGetDescent(ctFont)
    }
    let borderColor = Color(.systemBlue)

    var body: some View {
        HStack(spacing: 10) {
            Text("Copy")
                .font(.init(ctFont))
                .border(borderColor)
            
            Text("Copy")
                .font(.init(ctFont))
                .baselineOffset(ctFontDescender)
                .border(borderColor)
        }
    }

f:id:Koze:20200509020338p:plain:w508

見た目の結果は1つ前の実装と同じ。
でも今回の実装の場合 Text の箇所で Font を都度 CTFont から init するため、表示箇所が多い場合はFontUIFont を1つずつ作成する前回の実装の方が動作としては効率的な気もする。
コードの構造的には今回の CTFont を使った実装の方が整頓されていると思う。


現在の実装では baselineOffset で調整した分テキスト全体が上に移動するため、表示文字や周りのUIとの組み合わせによっては見た目が気になってくる。

f:id:Koze:20200509021829p:plain:w520
(↑文字全体が上にシフトしているのが気になる。日本語の場合特に。)

SwiftUI には便利な offsetpadding が存在するので、これらで baselineOffset による移動を再調整してやる。

offset

                Text("Copy")
                    .font(.init(ctFont))
                    .baselineOffset(ctFontDescender)
                    .border(Color(.systemRed))
                    .offset(y: ctFontDescender / 2)
                    .border(borderColor)

f:id:Koze:20200509070053p:plain:w498

padding

                Text("Copy")
                    .font(.init(ctFont))
                    .baselineOffset(ctFontDescender)
                    .border(Color(.systemRed))
                    .padding(.top, ctFontDescender)
                    .border(borderColor)

f:id:Koze:20200509070119p:plain:w500

これで見た目の問題も解消された。


最後にお好みでExtension化。
毎回の font baselineOffset offset or padding の操作を Extension にしてまとめてやることもできる。*3

extension Text {
    public func ctFont(_ ctFont: CTFont) -> some View {
        let descent = CTFontGetDescent(ctFont)
        return self.font(.init(ctFont))
            .baselineOffset(descent)
            .offset(y: descent / 2)
//            .padding(.top, ctFontDescender)
    }
}

この Extension の中で条件分岐して日本語フォント(もしくは問題のある特定のフォント)の場合のみ処理を入れて他のフォント利用時にはレイアウトに影響を与えない様にする事も検討できる。

extension CTFont {
    
    // quick example
    var isJapaneseFont1: Bool {
        let encoding = CTFontGetStringEncoding(self)
        return encoding == CFStringEncodings.macJapanese.rawValue
    }
    
    // quick example
    var isJapaneseFont2: Bool {
        let languages = CTFontCopySupportedLanguages(self) as? [String]
        return languages?.contains("ja") ?? false
    }
}

上記の日本語フォントの判定実装はちゃんと調査はしてないので取り急ぎの例として。

最終実装

Japanese font without Japanese character causes th ...

f:id:Koze:20200509070543p:plain:w498

*1:UIKit だと frame を大きく取る事で問題を解決できる。

*2:UIKit だと NSAttributedString の baselineOffset を使って問題を解決できる。

*3:SwiftUI のビルトインの Text Extension の実装を見ていると返り値で Text ではなくて some View を返してやるのはイレギュラーな感じはする。

既存プロジェクトで SwiftUI のプレビュー機能を使う Using SwiftUI preview in existing project

WWDC 2019 Session 233 Mastering Xcode Previews
https://developer.apple.com/videos/play/wwdc2019/233/
を見ていて、あれ、これもしかして Deployement Targe iOS 13以降のプロジェクトじゃなくても SwiftUI のプレビュー表示は使えるんじゃない?と思って試してみたらできたので共有。

iOS 13より前をサポートしている既存のプロジェクトで Xcode の New > File... で SwiftUI View を選択して作成。

f:id:Koze:20200305225313p:plain

ここでは例として TableViewCellPreview.swift を作成。
作成したファイルを開くとまず available in iOS 13 or newer のビルドエラーが起きるのでまず Xcode の自動Fixに従って @available(iOS 13.0, *) を設定をしてエラーを修正。

f:id:Koze:20200305225359p:plain

f:id:Koze:20200305225513p:plain

するとひとまずプレビューが表示される。

f:id:Koze:20200305225952p:plain

テンプレートで作成されている struct TableViewCellPreview: View は SwiftUI によるView実装で、既存クラスのプレビューだけなら不要なので削除。
struct TableViewCellPreview_Previews: PreviewProvider の名前もくどいのでstruct TableViewCellPreview: PreviewProvider に変更(お好みで)
前者の TableViewCellPreview を削除した事によりまたエラーが起きるので static var previews: some View の返り値は一旦適当な SwiftUI View にしておく。(例では Text("Text")

f:id:Koze:20200305230605p:plain

んで、以下のセッションで紹介されている UIView UIViewController を SwiftUI で使用する実装方法に沿って実装する。
WWDC 2019 Session 233 Mastering Xcode Previews
https://developer.apple.com/videos/play/wwdc2019/233/
WWDC 2019 Session 231 Integrating SwiftUI
https://developer.apple.com/videos/play/wwdc2019/231

実装例は以下
今回プレビューしたい TableViewCell は xib でレイアウトを作成しているので makeUIView のところで UINib からインスタンス化している。updateUIView は今回は空でOK。

Using SwiftUI preview with UIView

既存プロジェクトの TableViewCell の実装内容は以下
UILabel にはそれぞれ Title1, Subhead, Body のフォントを設定して、Automatically Adjusts Font をオンにして Dynamic Type に対応させている。
そして標準の UITableViewCell と同じ様に Dynamic Type の文字が Accessibility 対応の文字の大きさになっている(ContentSizeCategory.extraExtraExtraLarge より大きい)場合はセルのラベルを縦レイアウトにするという実装をしている。

f:id:Koze:20200305234153p:plain

Accessible TableViewCell like system UITableViewCe ...

*1

TableViewCellPreview によるプレビューをキャンバスに表示した結果は以下になる。

f:id:Koze:20200306042326p:plain

期待通りの結果!
実機 or Simulator での実行や Environment Overrides、デバイス設定の変更などせず複数の状況を一度にプレビューできるのが素晴らし過ぎる。
既存プロジェクトの Deployment Target はiOS 13より前を指定したまま実装内容は変更せず、プレビュー機能のみを追加させる事ができるので導入も容易。

注意

SwiftUI がリンクされた状態でiOS 13より前のiOSで動作させるためには SwiftUI.framework の Weak Link が必要なのでお忘れなく。

f:id:Koze:20200306003253p:plain

Tipsとリファクタ

Tips 1

SwiftUI はプレビュー用途で使っているので実装ファイル自体をリリース版に含めたくないと思うかも知れない。
ところが SwiftUI のプレビューが記述されたファイルをターゲットから外すと

Cannot preview in this file -- active scheme dones not build this file

とエラーが出てプレビュー表示できなくなった。

f:id:Koze:20200306004542p:plain

なので SwiftUI による実装自体をリリース版に含めたくなければ全体を #if DEBUG #endif で囲むと良いと思う。*2

Tips 2

SwiftUI のプレビューは最適化レベルが -Onone でないといけないらしく、Scheme で Run の Buile Configuration が Debug に設定されていなかったり、Build Settings で最適化レベルを変更している場合は以下のエラーが出てプレビュー機能が使えない。

Cannot preview in this file -- not building -Onone

f:id:Koze:20200306004822p:plain

Tips 3

WWDC のビデオによればプレビューの表示の際に UIApplicationDelegateapplication(_:didFinishLaunchingWithOptions:) を通るらしく、ここの処理は軽くした方が良いらしい。
処理の退避先は UISceneDelegatescene(_:willConnectTo:options:) なのでiOS 13以降のみ対応のアプリでしか使えないリファクタ。

Tips4 - Development Assets

プレビューに使うデータをネットワークから引っ張ってくる実装になっていると、プレビューのリフレッシュの度にネットワークアクセスが発生してしまうので*3、プレビューに必要なJSONや画像をスタブを使ってローカルから読み込む様にする。
プロジェクトにスタブを含めつつリリース版には同梱しない様にする素晴らしい新機能 Development Assets が Session 233 で紹介されていた。 https://developer.apple.com/videos/play/wwdc2019/233/ 15分38秒あたりから

Target > General の一番下に追加されている Development Assets
ここで指定したアセットやフォルダは開発時のみにターゲットに含まれる様になる。 *4
1, 2分でさらっと紹介されているけど便利過ぎる。*5

f:id:Koze:20200306001947p:plain

リファクタ

プレビュー用の SwiftUI のファイルは別途作成しなくても良い。
もし View の実装が軽いのであれば既存の実装ファイルに直接 PreviewProvider を実装するのもアリかと。
各Tipsとリファクタを踏まえて、また UIViewRepresentable の要求する typealias UIViewType を設定して previews makeUIView updateUIView の各メソッドから型の直書きを削除したリファクタバージョンは以下。
これで既存プロジェクトへの簡単な SwiftUI Preview の追加が完成。

Adding SwiftUI preview to existing class

まとめ

  • Deployment Target iOS 13以降じゃなくても Swift UI の部分に @available(iOS 13.0, *) を指定して SwiftUI によるプレビュー機能を使って開発を行う事ができる
  • SwiftUI.framework の Weak Link を忘れず
  • スタブは Development Assets を使う

*1:for SwiftUI preview のコメント部分は別記事書くかも

*2:WWDC のビデオでは #if DEBUG を使っているのをよく見かけたけど現在の Xcode 11 では SwiftUI View を作成した際のテンプレートで #if DEBUG が使われていない。気になって古い Xcode を引っ張ってきて SwiftUI View のテンプレートを確認して見たところ Xcode 11 beta 5 までのテンプレートではプレビュー部分を #if DEBUG で囲っており Xcode 11 beta 6 のテンプレートから #if DEBUG の指定が無くなった模様

*3:既存アプリの ViewController でプレビュー機能を試してみたらプレビュー内でちゃんと広告バナーまで表示されてそれはそれでプレビュー機能の凄さに感動した

*4:クラスファイルは対象ではないっぽい

*5:最近他のセッションビデオを色々見てるけど、このセッションでしか触れられていない様な、、こんな便利な機能なのに

UIGraphicsBeginImageContextWithOptions をやめて UIGraphicsImageRenderer を使うメリット

WWDCのメモリのセッションを見ていたら思わぬTipsについて言及されていた。

WWDC 2018 iOS Memory Deep Dive の20分57秒あたり https://developer.apple.com/videos/play/wwdc2018/416

UIGraphicsBeginImageContextWithOptions を使うのはやめて UIGraphicsImageRenderer を使えという話題。

UIGraphicsImageRenderer は最適なグラフィックフォーマットを自動で選択してくれる。
マスク画像の様な単色の画像を描画する場合に UIGraphicsBeginImageContextWithOptions では1ピクセル4バイトのsRGBに対して UIGraphicsImageRenderer は1ピクセル1バイトで75%のメモリの節約になる。*1

さらに UIGraphicsImageRenderer で作成された単色の UIImageUIImageViewtintColor などで表示色の変更が可能で、複数の色の画像が欲しい場合にそれぞれの色でレンダリングして異なる UIImage を作成しなくても tintColor を各ビューに設定するだけで済むとの事。

*1:UIGraphicsImageRenderer はワイドカラーにも対応しているので、単色の場合はメモリの節約になるけどワイドカラーが適用されるケースでは1ピクセル8バイトで逆にメモリ使用量は倍に増える。