はじめに
こちらは個人開発アプリができるまで by am10 Advent Calendar 2024の 22 日目の記事です。
22 日目は App Store 用のスクリーンショットを作成します。
2024 年 12 月時点では下記を見ると iPhone 用に 6.9 インチ(iPhone 16 Pro Max など)と iPad 用に 13 インチ(iPad Pro (M4)など)の 2 つがあればいいようです。
やること
ローカライズもあるので毎回手動で作成するのはなかなかの手間です。なので Swift Testing を使ってある程度自動化します。
こういうのを作成します。
処理の流れは下記です。
- 対象の SwiftUI の View(画面)を生成する
- UIHostingController を使って ViewController を生成する
- ViewController の View を UIImage で書き出す
- スクショ用の各端末のベゼルをつけた SwiftUI の View に UIImage をわたす
- 生成した SwiftUI の View で再度 1 ~ 3 を実行する
上記を Swift Testing で書いて testplan で各言語ごとに configuration を作ればテスト実行で各言語のスクショを作成してくれるようになります。
作成するのは年画面、月画面、登録画面の日本語と英語のスクショです。
各画面の UIImage 作成
まずは各画面の UIImage を作成します。現状だと月画面が表示できないので ContentView を少し修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// 追加 @State var selection = 0 TabView(selection: $selection) { // これ追加 YearView(year: $year, loadingState: $loadingState) .tabItem { Image(systemName: "y.circle.fill") Text("tab_year_title") }.tag(0) // これ追加 MonthView(year: $year, month: $month, loadingState: $loadingState) .tabItem { Image(systemName: "m.circle.fill") Text("tab_month_title") }.tag(1) // これ追加 } |
これで初期表示を月画面にできるようになりました。登録画面もリスト表示できるようにしたいので下記のように修正します。
1 2 |
// privateを削除 @State var items: [Item] = [] |
これで準備ができました。Test ターゲットに ScreenshotTests.swift ファイルを作成し下記のように実装します。
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 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
import Testing import SwiftUI import SwiftData @testable import ExpenseLog struct ScreenshotTests { @MainActor @Test func snapshot() { yearView() monthView() registerView() } @MainActor func yearView() { let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try! ModelContainer(for: Item.self, configurations: config) let context = container.mainContext let calendar = Calendar(identifier: .gregorian) context.addItems([ .init(id: "1", date: DateComponents(calendar: calendar, year: 2024, month: 1, day: 1).date!, name: "", price: 100, category: 0, categorySub: nil), .init(id: "2", date: DateComponents(calendar: calendar, year: 2024, month: 2, day: 15).date!, name: "", price: 150, category: 0, categorySub: nil), .init(id: "3", date: DateComponents(calendar: calendar, year: 2024, month: 3, day: 1).date!, name: "", price: 200, category: 0, categorySub: nil), .init(id: "4", date: DateComponents(calendar: calendar, year: 2024, month: 4, day: 1).date!, name: "", price: 200, category: 0, categorySub: nil), .init(id: "5", date: DateComponents(calendar: calendar, year: 2024, month: 5, day: 1).date!, name: "", price: 200, category: 0, categorySub: nil), .init(id: "6", date: DateComponents(calendar: calendar, year: 2024, month: 6, day: 1).date!, name: "", price: 200, category: 0, categorySub: nil), .init(id: "7", date: DateComponents(calendar: calendar, year: 2024, month: 7, day: 1).date!, name: "", price: 200, category: 0, categorySub: nil), .init(id: "8", date: DateComponents(calendar: calendar, year: 2024, month: 8, day: 1).date!, name: "", price: 200, category: 0, categorySub: nil), .init(id: "9", date: DateComponents(calendar: calendar, year: 2024, month: 9, day: 1).date!, name: "", price: 200, category: 0, categorySub: nil), .init(id: "10", date: DateComponents(calendar: calendar, year: 2024, month: 10, day: 1).date!, name: "", price: 300, category: 0, categorySub: nil), .init(id: "11", date: DateComponents(calendar: calendar, year: 2024, month: 11, day: 1).date!, name: "", price: 250, category: 0, categorySub: nil), .init(id: "12", date: DateComponents(calendar: calendar, year: 2024, month: 12, day: 1).date!, name: "", price: 200, category: 1, categorySub: nil), ]) let imageData = screenshot(ContentView( year: "2024", month: "12", selection: 0 ).modelContext(context)) } @MainActor func monthView() { let config = ModelConfiguration(isStoredInMemoryOnly: true) let container = try! ModelContainer(for: Item.self, configurations: config) let context = container.mainContext context.addItems([ .init(id: "1", date: Date(), name: "", price: 100, category: 0, categorySub: nil), .init(id: "2", date: Date(), name: "", price: 150, category: 0, categorySub: nil), .init(id: "3", date: Date(), name: "", price: 200, category: 0, categorySub: nil), .init(id: "4", date: Date(), name: "", price: 200, category: 1, categorySub: nil), .init(id: "5", date: Date(), name: "", price: 250, category: 1, categorySub: nil), .init(id: "6", date: Date(), name: "", price: 300, category: 1, categorySub: nil), ]) let imageData = screenshot(ContentView( year: String(Calendar(identifier: .gregorian).component(.year, from: Date())), month: String(Calendar(identifier: .gregorian).component(.month, from: Date())), selection: 1 ).modelContext(context)) } @MainActor func registerView() { let imageData = screenshot( RegisterView( isRegistered: .init( get: { false }, set: { _ in }), items: [ .init(id: "1", date: Date(), name: "ほげ", price: 200, categoryValue: 0, categorySubValue: 0), .init(id: "2", date: Date(), name: "ふが", price: 1200, categoryValue: 1, categorySubValue: 0), .init(id: "3", date: Date(), name: "ふー", price: 250, categoryValue: 1, categorySubValue: 1), .init(id: "4", date: Date(), name: "ぴよ", price: 23000, categoryValue: 0, categorySubValue: 1), .init(id: "5", date: Date(), name: "ほげほげ", price: 2100, categoryValue: 0, categorySubValue: 2) ])) } } extension ScreenshotTests { @MainActor func screenshot(_ view: some View) -> Data { let window = UIWindow(frame: UIScreen.main.bounds) let vc = UIHostingController(rootView: view) window.rootViewController = vc window.makeKeyAndVisible() vc.view.layoutIfNeeded() vc.view.setNeedsLayout() return vc.view.snapshot().pngData()! } } extension UIView { func snapshot() -> UIImage { UIGraphicsBeginImageContextWithOptions(self.bounds.size, true, UIScreen.main.scale) drawHierarchy(in: bounds, afterScreenUpdates: true) let img = UIGraphicsGetImageFromCurrentImageContext()! UIGraphicsEndImageContext() return img } } |
これで各画面の UIImage が生成できるようになりました。UIImage よりも Data の方が扱いやすかったので pngData() で Data に変換しています。
スクショ用 View 作成
続いてスクショ用の View を作成します。下記サイトで必要な端末のベゼル画像を取得します。
ベゼル付き View の表示はこちらを参考にしました。
Test ターゲットに Assets.xcassets を追加して取得したベゼル画像を追加します。
ScreenshotView.swift ファイルを作成し下記のように実装します。
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 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
import SwiftUI enum Device { case iPhone16ProMaxPortrait case iPadPro13Portrait var image: Image { switch self { case .iPhone16ProMaxPortrait: return Image(.iPhone16ProMaxPortrait) case .iPadPro13Portrait: return Image(.iPadPro13Portrait) } } var radius: CGFloat { return self == .iPhone16ProMaxPortrait ? 80 : 0 } var scale: CGFloat { switch self { case .iPhone16ProMaxPortrait: return 0.25 case .iPadPro13Portrait: return 0.4 } } var padding: CGFloat { switch self { case .iPhone16ProMaxPortrait: return 16 case .iPadPro13Portrait: return 32 } } } struct ScreenshotView: View { let device: Device let title: String let imageData: Data let color: Color var body: some View { ZStack { color.ignoresSafeArea() VStack { Text(title) .font(.title) .fontWeight(.bold) .foregroundColor(Color.black) GeometryReader { proxy in ZStack { Image(uiImage: .init(data: imageData)!) .clipShape(RoundedRectangle(cornerRadius: device.radius)) device.image } .scaleEffect(device.scale) .frame(width: proxy.size.width, height: proxy.size.height) .clipped() } } .padding(device.padding) .ignoresSafeArea(.all, edges: [.bottom, .horizontal]) } } } |
ScreenshotTests に下記を追加すればベゼル付きスクショを作成できます。
1 2 3 4 5 6 7 8 |
// 追加 let device: Device = .iPhone16ProMaxPortrait let imageData // それぞれの画面の画像 // ここ追加 let screenshot = screenshot(ScreenshotView( device: device, title: "ほげ", imageData: imageData, color: .red )) |
注意点としてはテスト起動時は iPhone 16 Pro Max か iPad Pro 13-inch(M4)を選択し下記の device をそれぞれ合ったものに変更する必要があります。
1 |
let device: Device = .iPhone16ProMaxPortrait |
ローカライズ
続いてスクショのローカライズをします。
Test ターゲットに Localizable.xcstrings ファイルを追加しそれぞれのスクショに表示する文言を設定します。
下記のように ScreenshotTests を修正すればローカライズできます。
1 2 3 4 5 6 7 8 9 10 11 12 |
let screenshot = screenshot(ScreenshotView( device: device, title: localized("hoge"), // ここ修正 imageData: imageData, color: .red )) extension ScreenshotTests { /// String(localized:)だとうまくいかなかったので一工夫 func localized(_ key: String) -> String { class Tmp {} return Bundle(for: Tmp.self).localizedString(forKey: key, value: nil, table: nil) } } |
つづいてローカライズ分も一気にスクショを作成するために TestPlan.xctestplan ファイルを追加します。
Tests > Choose Targets... でターゲットを追加します。
必要なのは ScreenShotTests のみなので他のチェックは外します。
Configurations で日本語と英語用に ja, en を追加します。Target for Variable Expansion と Application Language を変更します。
(Target for Variable Expansion は Shared Settings の方で変えても OK です)
これで ScreenShotTests を実行すると日本語用と英語用で 2 回実行されるようになりました。
スクショを保存
あとは生成した画像を任意のディレクトリに保存するだけです。
TestPlan > Configurations > Shared Settings > Arguments > Environment Variables に下記のように値を追加します。
プロジェクトのルートに Screenshots ディレクトリを追加します。
ScreenShotTests を下記のように修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
let screenshot: Data // 各画面のスクショ // これ追加 saveScreenshot(screenshot, fileName: "hoge") @MainActor var folderName: String { let names = UIDevice.current.name.components(separatedBy: " ") let index = names.firstIndex(where: { $0 == "iPhone" || $0 == "iPad" }) ?? 0 return names.suffix(names.count - index).joined(separator: " ") } @MainActor func saveScreenshot(_ imageData: Data, fileName: String) { let fileManager = FileManager.default let directoryPath = ProcessInfo.processInfo.environment["SCREENSHOTS_DIR"]! + "/\(self.folderName)" if !fileManager.fileExists(atPath: directoryPath) { try? fileManager.createDirectory(atPath: directoryPath, withIntermediateDirectories: false, attributes: nil) } let path = "\(directoryPath)/\(Locale.current.identifier)_\(fileName).png" fileManager.createFile(atPath: path, contents: imageData, attributes: nil) } |
これでプロジェクトルートの Screenshots ディレクトリに iPhone と iPad にディレクトリをわけてスクショが保存されるようになりました!
生成したスクショはこんな感じです。
日本語 | 英語 |
---|---|
日本語 | 英語 |
---|---|
おわりに
iPhone 用と iPad 用で 2 回テストを実行する必要はありますが手動でやるよりははるかに楽でしょう!
ストア用のスクショも作成できたのでリリースももうすぐできそうです!!
明日はプライバシーマニュフェスト対応をします。
コメント