酢ろぐ!

カレーが嫌いなスマートフォンアプリプログラマのブログ。

SwiftUI で検索モードを解除する。dismissSearch() は searchableモディファイアを設定したViewの 子View でないと使えない

本文はあとで書く。

developer.apple.com

dismissSearch() を実行すれば、検索モードがオフになり、検索バーに入力していたテキストもキーボードも消える。

  • Sets isSearching to false.
  • Clears any text from the search field.
  • Removes focus from the search field.

当初は、以下のように実装した。しかし dismissSearch() を実行してもうまく動かなかった。

struct ParentView: View {
    @State private var searchText = ""

    @Environment(\.dismissSearch) private var dismissSearch
    
    var body: some View {
        VStack {
            Text(searchText)
            Button(action: { dismissSearch() } ) {
                Text("Dissmiss search")
            }
        }
            .searchable(text: $searchText)
    }
}

dismissSearch() は searchable モディファイアを設定した、子View でないと有効ではなかった。

struct ParentView: View {
    @State private var searchText = ""

    var body: some View {
        ChildView(searchText: searchText)
            .searchable(text: $searchText)
    }
}

struct ChildView: View {
    @Environment(\.dismissSearch) private var dismissSearch

    let searchText: String

    var body: some View {
        Text(searchText)
        Button(action: { dismissSearch() } ) {
            Text("Dissmiss search")
        }
    }
}

Swift で上付き文字・下付き文字を表示させる

iOSアプリで「H₂O」や「x²」のような上付き文字や下付き文字を表示する方法について調査する機会があった。

さくさんは、2010年頃(iPhone OS 3.xの時代)からiOSアプリの開発を行っているが、上付き文字や下付き文字を扱うのは今回が初めてだった。他の多くの方もあまり使わないのか、Swiftで上付き文字や下付き文字を扱う方法について説明している記事を見つけることができなかった。

上付き文字・下付き文字とは

Unicodeのコードポイントを指定すれば、上付き文字や下付き文字を表示できるだろうことは理解していたが、その「小さな文字が横についている状態」を何と呼ぶのかを調査するのに苦労した。

Wikipediaによると、上付き文字や下付き文字は、基準となる文字の上部または下部に書かれる付記文字のようだ。英語では、上付き文字は「superscript」、下付き文字は「subscript」と呼ばれる。

頻繁に使用するであろう数字の上付き文字や下付き文字は以下の表にまとめた。

通常文字 上付き文字 下付き文字 Unicode (上付き) Unicode (下付き)
0 U+2070 U+2080
1 ¹ U+00B9 U+2081
2 ² U+00B2 U+2082
3 ³ U+00B3 U+2083
4 U+2074 U+2084
5 U+2075 U+2085
6 U+2076 U+2086
7 U+2077 U+2087
8 U+2078 U+2088
9 U+2079 U+2089
a U+1D43 U+2090

文字列リテラルとして定義する場合

プログラム内で上付き文字や下付き文字を直接的な文字列リテラルとして扱いたい場合、以下のように定義することができる。

let str1 = "H\u{2082}O" // 下付き文字 2 を含む文字列 ("H₂O")
let str2 = "x\u{00B2}" // 上付き文字 2 を含む文字列 ("x²")

文字列リソースとして定義する場合

iOSアプリでは Localizable.strings を用いることでローカライズが可能だ。しかし、上付き文字や下付き文字を文字列リソースとして定義する際には、先ほど述べた文字列リテラルをそのままコピー&ペーストして使うことはできない。記述方法が異なるため、注意が必要である。

"key_1" = "H\U2082O";
"key_2" = "x\U00B2";

サンプルコード

この記事を作成する過程で利用したSwiftUIのサンプルコードを以下に示す。

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(spacing: 16) {
            Text("H\u{2082}O")
                .font(.system(size: 48))
            
            Text("x\u{00B2}")
                .font(.system(size: 48))
            
            Text("key_1")
                .font(.system(size: 48))
                .foregroundColor(.green)
            
            Text("key_2")
                .font(.system(size: 48))
                .foregroundColor(.green)
        }
        .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

上記のコードを実行した結果は以下の通り。

CoreML 向けの mlmodel モデルで物体検出/物体識別が可能なラベルの一覧を取得する

CoreMLでは画像識別/オブジェクト検出をおこなう機械学習モデルは .mlmodel または .mlpackage ファイルである。モデルファイルをダブルクリックするとXcodeが開き、下図のようにメタデータが表示される。

ここに表示されている Class Labels が検出または識別可能なオブジェクトの一覧であるが一括でコピペすることができない。

テキストファイルで Class Labelsの一覧が欲しいので 100個までは手作業で入力あるいは一行ずつコピペしたが、画像識別モデルで有名な MobileNet v2 はラベルが1,000個もありおおよそ現実的ではないと判断して、モデルのメタデータを参照する方法を調べた。

mlmodel ファイルで検出または識別可能なラベルの一覧を取得する

任意の mlmodel ファイルにおいて、オブジェクト検出/画像識別が可能なラベルの一覧を取得する。

if let mlModel = try? MobileNetV2FP16(configuration: MLModelConfiguration()).model {
    let classLabels = mlModel.modelDescription.classLabels
    
    print("### \(mlModel.configuration.modelDisplayName!)\n")
    classLabels?.forEach({ label in
        print("\(label)")
    })
    print("\n")
}

上記のサンプルコードを実行すると以下のリストが得られる。

### MobileNetV2FP16

background
tench, Tinca tinca
goldfish, Carassius auratus
great white shark, white shark, man-eater, man-eating shark, Carcharodon carcharias
tiger shark, Galeocerdo cuvieri
hammerhead, hammerhead shark
... 以下略

iOS 16 の場合、SwiftUI の List で セクションヘッダー上部にマージンが入ってしまう

iOS 15 / iOS 16 の場合、SwiftUI の List で セクションヘッダーに top padding (top margin?) が入ってしまう問題がある。

ヘッダー上部にマージンが入る現象自体は iOS 15 の時にも発生していたが UITableView.appearance().sectionHeaderTopPadding = 0 で解決できた。iOS 16 からは List の内部実装が UITableView から UICollectionView に変わったようで、iOS 16 ではこの対策は使えなくなった。

この手の問題は、たとえば List から UITableView を取り出す SwiftUI-Introspect などを使えばもっと簡単に解決するのかもしれないが、Apple の都合で内部実装が変わってしまう現状を鑑みると適切な対応ではないだろう。

本記事ではそれっぽい対応策を記載しているが、iOS 15 の場合とは異なり実践では使えないかもしれない。

結論からいえば、過去のデザイン(iOS 14 での表示)に拘らずに、iOSのバージョンごとの SwiftUI でのデフォルト表示に任せるのが一番良いだろう。

iOS 15 で SwiftUI の List で セクションヘッダー上部にマージンが入ってしまう

Xcode 13 登場時に話題になった。SwiftUI だけの問題ではなく UITableView への変更だったので UITableViewController を継承した画面にも下記のようなコードをたくさん埋め込んだ。

    override func viewDidLoad() {
        super.viewDidLoad()
//...
        if #available(iOS 15.0, *) {
            tableView.sectionHeaderTopPadding = 0
        }
    }

一方、SwiftUI の List ではどのように対策したかといえば、前述したように UITableView.appearance().sectionHeaderTopPadding = 0 を実行して対策した。

struct ListView: View {
    
    var body: some View {
        List {
            Section {
                Text("🍎")
                Text("🍊")
                Text("🍓")
            } header: {
                Text("Red")
                    .listRowInsets(EdgeInsets())
                    .frame(maxWidth: .infinity)
                    .background(Color.red)
            }
            Section {
                Text("🥒")
                Text("🥬")
                Text("🫑")
            } header: {
                Text("Green")
                    .listRowInsets(EdgeInsets())
                    .frame(maxWidth: .infinity)
                    .background(Color.green)
            }
        }
        .navigationTitle("iOS \(UIDevice.current.systemVersion)")
        .navigationBarTitleDisplayMode(.inline)
        .listStyle(.plain)
        .onAppear {
            if #available(iOS 15.0, *) {
                UITableView.appearance().sectionHeaderTopPadding = 0
            }
        }
    }
}

iOS 16 で SwiftUI の List で セクションヘッダー上部にマージンが入ってしまう

単に見た目を合わせるだけであれば「How to remove section header top padding in SwiftUI Plain List with iOS16 - Stack Overflow」を参考にして対応することができる。

var layoutConfig = UICollectionLayoutListConfiguration(appearance: .plain)
layoutConfig.headerMode = .supplementary
layoutConfig.headerTopPadding = 0

let listLayout = UICollectionViewCompositionalLayout.list(using: layoutConfig)
UICollectionView.appearance().collectionViewLayout = listLayout

コード全体は以下の通り。

struct ListView: View {
    
    var body: some View {
        List {
            Section {
                Text("🍎")
                Text("🍊")
                Text("🍓")
            } header: {
                Text("Red")
                    .listRowInsets(EdgeInsets())
                    .frame(maxWidth: .infinity)
                    .background(Color.red)
            }
            Section {
                Text("🥒")
                Text("🥬")
                Text("🫑")
            } header: {
                Text("Green")
                    .listRowInsets(EdgeInsets())
                    .frame(maxWidth: .infinity)
                    .background(Color.green)
            }
        }
        .navigationTitle("iOS \(UIDevice.current.systemVersion)")
        .navigationBarTitleDisplayMode(.inline)
        .listStyle(.plain)
        .onAppear {
            if #available(iOS 16.0, *) {
                var layoutConfig = UICollectionLayoutListConfiguration(appearance: .plain)
                layoutConfig.headerMode = .supplementary
                layoutConfig.headerTopPadding = 0

                let listLayout = UICollectionViewCompositionalLayout.list(using: layoutConfig)
                UICollectionView.appearance().collectionViewLayout = listLayout
            } else if #available(iOS 15.0, *) {
                UITableView.appearance().sectionHeaderTopPadding = 0
            }
        }
    }
}

懸念点

ただこの方法はあまり筋がよくない。たとえば SmartNews のような上部タブがあるアプリの場合、上部タブはおそらく UICollectionView を使って実装されている。UICollectionView.appearance() に対しての変更処理をおこなっているので、競合をおこして上部タブの表示が狂ってしまうだろう。

Xcode 14.0 と iOS 16 シミュレータの使用時、AdMob SDKの初期化をおこなうとセキュリティワーニングが表示される

アプリの起動時に紫色の警告が表示されるようになった。

調査したところ、Xcode 14.0 と iOS 16 シミュレータの使用時に AdMob SDKの初期化をおこなうと、このセキュリティワーニングが表示されるようだ。

AdMob SDKの初期化をおこなうとセキュリティワーニングが表示される

Xcode 14.0 と iOS 16 シミュレータ を使用している場合、アプリ起動時に GADMobileAds.sharedInstance().start(completionHandler: nil) を実行すると下記のようなエラーが発生する。

[Security] This method should not be called on the main thread as it may lead to UI unresponsiveness.
(意訳) [セキュリティ] このメソッドは、UIが応答しなくなる可能性があるため、メインスレッドで呼び出すべきではありません。

アプリでの実装は、2022/09/24時点の「スタートガイド  |  iOS  |  Google Developers」で提示されているサンプルコードと同じである。

import GoogleMobileAds

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

  func application(_ application: UIApplication,
      didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {

    GADMobileAds.sharedInstance().start(completionHandler: nil)

    return true
  }

}

どうしてこのセキュリティワーニングが表示されるのか?

セキュリティワーニングが表示されている理由として、1ヶ月前に投稿された「Xcode: Method called om main thread causing unresponsiveness」に回答を見つけた。

I just want to circle back on this. The team mentioned that this is a bug with Xcode 14 beta 6 / WebKit and that Apple will need to fix it.
(意訳) この件に関して周知したいと思います。これは Xcode 14 beta 6 / WebKit のバグであり、Apple が修正する必要があると言及しました。

さらに Apple のフォーラム「Xcode 14 & iOS 16 purple warni… | Apple Developer Forums」に以下のように推測している方がいた。

I'm going to assume someone on the Xcode team chose to add this warning in the event a developer doesn't account for slow loading or not loading at all an initial wkwebview when the app first launches? Any other theories? Is it safe to ignore the warning?
(意訳) Xcodeチームの誰かが、開発者がアプリを最初に起動したときに、読み込みが遅いことや、最初のwkwebviewを全く読み込まないことを考慮しない場合に、この警告を追加することにしたのだと推測しています。他の説はありますか?警告を無視しても大丈夫ですか?

これらのことからセキュリティワーニングが表示される理由を推測した。

  • AdMob SDKの初期化時にプリロードがおこなわれる
  • プリロードとは、WebKitでのコンテンツの読み込みが発生
  • Xcode 14.0 ではアプリ起動時にWebKit の読み込みを許さない

Apple に認識されているこの問題であるが、2022/09/24現在の Xcode 14.0 Release でも発生しているし、Xcode 14.1 Beta にも今のところ WebKit に関する修正は現在のところリリースノートに明記されていない。

Apple側で修正されるのかGoogle側で回避されるのかわからないが、いちアプリ開発者としては現状問題は発生していないので成り行きを見守ることとする。

Swiftで 背景色に応じた見やすい文字色を求める

ユーザーがタグ色を選択できるプロダクトの場合、ユーザーが決めた色の上にテキストを配置しなければいけない。たとえば、下図の GitHub のタグのような場合である。

背景色に応じて、視認しやすいテキストカラーが利用されていることがわかる。

  • enhancement の背景色は #b5f0f2 で、テキスト色は黒色
  • task の背景色は #2b76db で、テキスト色は白色

当初は YCbCr や HSV の輝度を使って判断しようと考えていたが、調査を進めたところ、背景色に応じた見やすい文字色を求めるには WCAG 2.0 を利用するのが良いことがわかった。

背景色に応じて見やすい文字色を求める

元ネタの C# 版を Swift版に書き換えた。

import UIKit

struct ColorUtils {

    static func relativeLuminance(_ color: CGColor) -> CGFloat {
        let sRGBColorSpace = CGColorSpace(name: CGColorSpace.extendedSRGB)!
        let sRGB = color.converted(
            to: sRGBColorSpace,
            intent: .defaultIntent,
            options: nil)!
            .components!
        let adjusted = sRGB.map { (c) -> CGFloat in
            if c <= 0.03928 {
                return c / 12.92
            } else {
                return pow((c + 0.055)/1.055, 2.4)
            }
        }
        return 0.2126 * adjusted[0] +
            0.7152 * adjusted[1] +
            0.0722 * adjusted[2]
    }

    static func foregroundTextColor(_ background: UIColor) -> UIColor {
        // 背景色の相対輝度
        let background = relativeLuminance(background.cgColor)

        // 黒文字と白文字の判定に使う相対輝度境界値
        let border: CGFloat = 0.17912878474779

        // 背景色の相対輝度が境界値以上なら黒文字、未満なら白文字
        return background >= border ? UIColor.black : UIColor.white
    }
}

参考記事

iOS/macOSでFFmpegを使ってh264エンコードすると画質が悪くなる問題 (h264_videotoolbox vs libx264)

f:id:ch3cooh393:20220306131429p:plain

iOSアプリにtanersener/ffmpeg-kitを組み込んで、iPhoneで録画した動画をffmpegを使ってh264エンコードするとどの動画にもノイズが入る問題が発生した。macOS上で同じ動画をエンコードした場合にはノイズが乗らない。なぜノイズの有無が発生してしまうのか? この差異を認識するのに随分時間をかけてしまった。

結論としてはffmpegで使われるエンコーダーが異なるのが原因である。iOSアプリ上ではApple社が実装したコーデックの h264_videotoolbox が使われ、macOS上では libx264 が使われる。h264_videotoolboxはハードウェアエンコードをおこなうのでエンコード時間は短くて済むが画質が悪くなってしまう。

h264 といったエイリアスを使うのではなく、厳密な名称である h264_videotoolboxlibx264 を指定すれば、macOS上でも同等の現象を再現できる。

画質が悪いことに気づいた経緯

以下のパラメータを使って動画のエンコードをした。テスト動画を用いて複数のパラメータを使ってエンコードして、サイズと画質のバランスが良いパラメータを選んだ。

ffmpeg -i original.mov -c:v h264 -b:v 1000k output_h264.mp4

しかしiOS上で動画をエンコードすると下図のようにブロックノイズが乗ってしまう。期待する画質が得られないことがわかった。

f:id:ch3cooh393:20220306120811p:plain
左:macOSでエンコードした動画(libx264が使われている)。右:iOSでエンコードした動画(h264_videotoolboxが使われている)。

当初は同じh264エンコーダーを指定しているのに何故画質に違いが出るのかわからなかった。

-c:v h264 はエイリアスであり内部的には異なるコーデックが使われていた

ffmpegに詳しい方にとっては当たり前のことなのかもしれないが、-c:v h264 はエイリアスであり内部的には異なるコーデックが使われていた。これは数日調査してわかった。

homebrew経由でインストールした ffmpeg で利用可能なコーデックを調べると以下の結果が得られる。

$ffmpeg -codecs

 DEV.LS h264                 H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (encoders: libx264 libx264rgb h264_videotoolbox )

iOSアプリ上で ffmpeg -codecs を実行してみたところ以下の結果が得られた。iOSアプリ上では libx264 を使うことができない*1

 DEV.LS h264                  (encoders: h264_videotoolbox )

つまり -c:v h264 はエイリアスであり、h264を指定するとmacOSでは libx264 のエンコーダーが使われ、iOSでは h264_videotoolbox のエンコーダーが使われることがわかった。

エンコードパラメータとして h264 を使うのではなく、より厳密な h264_videotoolboxlibx264 を指定していればもっと早く画質が悪い原因が特定できていたと思う。

h264_videotoolbox を使って高画質動画にするためにはビットレートを上げる

iOSでは h264_videotoolbox を使わないといけないが、libx264 の感覚でビットレートを指定すると低画質になってしまう。h264_videotoolboxエンコーダーを使って高画質の動画にするにはどうすればよいか? 答えはStack Overflowに書かれていた。

SOのAnswerによると以下の通りである。ここでは libx265 と書かれているがこのまま libx264 と読み変えられる。

多くのハードウェアアクセラレーションエンコーダと同様に、hevc_videotoolbox は libx265 ほど効率的ではありません。そのため、libx265と同等の品質を実現するには、かなり高いビットレートを与えなければならないかもしれません。これは、H.264からHEVC/H.265に再エンコードする目的を失うかもしれません

h264_videotoolbox を使う場合には libx264 より高いビットレートを指定しなければいけないことがわかった。どこまでビットレートを上げれば同等くらいの画質になるのか検証した。ここでの「同等くらいの画質」はさくさん基準によるため、各位には動画の性質によって適切な求めてもらいたい。そして良さげなビットレート設定を教えてもらえると嬉しい。

libx264(1000k) vs h264_videotoolbox(5000k)

h264_videotoolboxでの指定ビットレートを1000kから5000kに変更した。パラメータは下記のように指定している。

ffmpeg -i original.mov -c:v h264_videotoolbox -b:v 5000k output_videotool_5000k.mp4

h264_videotoolboxでもビットレートを -b:v 5000k まであげることで、libx264の動画よりも高画質にすることができた。しかしlibx264の動画サイズは 17.7MB に対して、h264_videotoolboxの動画サイズは 78.9MB とかなり大きくなってしまった。

f:id:ch3cooh393:20220306123934p:plain
左:libx264を使って-b:v 1000k指定した動画。右:h264_videotoolboxを使って-b:v 5000k指定した動画。

libx264(1000k) vs h264_videotoolbox(2000k)

h264_videotoolboxでの指定ビットレートを1000kから2000kに変更した。パラメータは下記のように指定している。

ffmpeg -i original.mov -c:v h264_videotoolbox -b:v 2000k output_videotool_2000k.mp4

h264_videotoolboxの指定ビットレートを -b:v 2000k にあげることで、libx264の動画と同等の画質にすることができた。libx264の動画サイズは 17.7MB に対して、h264_videotoolboxの動画サイズは 32.9MB と2倍程度に抑えることができている。

f:id:ch3cooh393:20220306124406p:plain
左:libx264を使って-b:v 1000k指定した動画。右:h264_videotoolboxを使って-b:v 2000k指定した動画。

結論

以上のことからffmpegで使われるエンコーダーが異なるのが原因であることがわかった。

iOSアプリ上ではApple社が実装したコーデックの h264_videotoolbox が使われ、macOS上では libx264 が使われる。h264_videotoolboxはハードウェアエンコードをおこなうのでエンコード時間は短くて済むが画質が悪くなってしまう。macOSでも-c:v h264_videotoolboxと指定すればハードウェアエンコーダーを使うことができる。

M1 MacBook Proを使っていてもlibx264を使うと等倍でしかエンコードできない。これは10分の動画をエンコードするのに10分かかってしまうことを意味している。対して h264_videotoolbox は6倍でエンコードできる。これは10分の動画を約1.6分でエンコードできることを意味している。

ユーザーを待たせることができるのであれば*2libx264一択で良いだろう。画質面・ファイルサイズ面で優れている。もしユーザーを待たせることができないのであれば h264_videotoolbox を使い、指定ビットレートを上げてファイルサイズを犠牲にして画質を上げる必要がある。

*1:厳密的には異なるが本記事での紹介は省く

*2:そしてライセンス的な問題がなければ