🛠️

AppStore向けPreview画像を半自動生成してくれるツール作った

2023/10/21に公開

アプリの個人開発において、最も面倒臭い作業はAppStore向けのPreview画像を用意することではないでしょうか?必須の端末だけでもiPhone 14 Pro Max(6.7 inch)、iPhone 8 Plus(5.5 inch)、iPad Pro 6th Generation(12.9 inch)、iPad Pro 2nd Generation(12.9 inch)の4種類があり(ソース)、多言語対応している場合はその言語の数だけ必要なPreview画像が倍々で増えます。

私は普段言語ごとのスクリーンショットを手動で撮って、Photoshopで装飾してPreview画像を用意していたのですが、あまりにも面倒臭いので、せめてPhotoshopで装飾する部分だけでも自動化したいと思い仕組みを作りました。

AppPreviewMaker

https://youtu.be/VwiY6rtGRf4

このツールは、事前にアプリのスクリーンショットが用意できていれば、それを端末ごとにベゼルに入れて見出しも添えた画像を自動で生成してくれます。


こういうやつ

iOS Simulatorの端末を選んで、それに対応したUITestのテストケースを実行すればスクリーンショットとしてテスト結果にAttachmentsを残してくれます。

ツールの開発にあたって

まず、端末のベゼルについてですが、Appleはデザインリソースとして製品ベゼルを配布してくれています。
https://developer.apple.com/jp/design/resources/#product-bezels

元となるアプリのスクリーンショットは端末の画面サイズと同じため、ベゼルにはめ込んだ画像を素直に表示しようとすると画面からはみ出してしまいます。そのため、scaleEffect()を使ってリサイズすることにしたのですが、タイトルも添えられる様に表示するには工夫が必要でした。

失敗例
VStack {
    Text("見出し")
        .font(.title)
        .fontWeight(.bold)
        .foregroundColor(Color.black)
    ZStack {
        Image("オリジナルのスクリーンショット")
        Image("端末のベゼル")
    }
    .scaleEffect(0.2)
}

この様に単純にscaleEffect()だけを使うと元となった画像の大きさだけ領域が確保されてしまいTextが画面内に表示できませんでした。そこで、表示領域を指定した上でトリミングする必要がありました。

成功例
VStack {
    Text("見出し")
        .font(.title)
        .fontWeight(.bold)
        .foregroundColor(Color.black)
    GeometryReader { proxy in
        ZStack {
            Image("オリジナルのスクリーンショット")
            Image("端末のベゼル")
        }
        .scaleEffect(0.2)
        .frame(width: proxy.size.width, height: proxy.size.height)
        .clipped()
    }
}

また、多言語対応している分のスクリーンショットも一括で生成したかったため、端末の言語指定に関わらず英語用の画像と日本語用の画像を表示できる様に指定しました。これはAsset Catalogsに登録した画像を多言語対応させた上でenvironment()を指定することで簡単にできます。

SomeView()
    .environment(\.locale, .init(identifier: "en"))
SomeView()
    .environment(\.locale, .init(identifier: "ja"))

最後に、端末の選択をPickerで行える様にしていたのですが、UITestでこれを操作するのに手こずりました。accessibilityIdentifier()を指定すれば、一見XCUIApplication().pickersでPickerを取得できそうなのですができませんでした。実際にはPickerではなくButtonな様です。

ということで、以下の様な形で操作しました。

Picker(selection: $device) {
    ForEach(Device.allCases) { device in
        Label {
            Text(device.title)
        } icon: {
            device.icon
        }
        .accessibilityIdentifier(device.prefix)
        .tag(device)
    }
} label: {
    Text("Device")
}
.accessibilityIdentifier("devicePicker")
テスト側
app.buttons["devicePicker"].tap()
app.buttons[device.prefix].tap()

Discussion