├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── LICENSE
├── Package.swift
├── README.md
└── Sources
└── Blacksmith
├── Helpers
├── ExportMarketingImageToURL.swift
├── ExportSize.swift
├── ImageBackground.swift
├── Localization.swift
├── TitleAlignment.swift
├── ViewToNSImage.swift
└── ViewToUIImage.swift
└── Views
├── CapturingView.swift
└── ScreenshotWithTitle.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Florian Schweizer
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.5
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "Blacksmith",
8 | platforms: [
9 | .macOS(.v10_15),
10 | .iOS(.v13),
11 | ],
12 | products: [
13 | .library(
14 | name: "Blacksmith",
15 | targets: ["Blacksmith"]),
16 | ],
17 | dependencies: [],
18 | targets: [
19 | .target(
20 | name: "Blacksmith",
21 | dependencies: []),
22 | ]
23 | )
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Blacksmith
2 |
3 | Support the project:
4 |
5 | Previously distributed through: Gumroad ($0+)
6 |
7 | Automated and localized App Store Screenshots without leaving Xcode.
8 |
9 | ## Installation Instructions
10 | Blacksmith supports installation via Swift Package Manager.
11 | To install, add the package via this url:
12 |
13 | ```
14 | https://github.com/chFlorian/Blacksmith
15 | ```
16 |
17 | ## Usage
18 |
19 | Blacksmith is used to automatically generated simple App Store screenshots for multiple localizations.
20 |
21 | ### 1. Add Blacksmith to your UITest target & import Blacksmith
22 |
23 | ```swift
24 | import Blacksmith
25 | import XCTest
26 | import SwiftUI
27 |
28 | class ForgeUITests: XCTestCase {
29 | ```
30 |
31 | ### 2. Create a testX function for each screen/locale that you want to create an image of
32 |
33 | ```swift
34 | func testScreenshot_AddElementToLayoutBuilder() throws {
35 | let app = XCUIApplication()
36 | ```
37 |
38 | ### 3. Specify a language that should be used for the export
39 |
40 | ```swift
41 | app.launchArguments = ["-AppleLanguages", "(en)"]
42 | app.launch()
43 | ```
44 |
45 | ### 4. Navigate to the screen that should be exported
46 |
47 | ```swift
48 | let window = app.windows.firstMatch
49 | window.outlines.buttons["🛠 Layout Builder"].click()
50 |
51 | let addElementButton = window.toolbars.buttons["Add Element"]
52 | addElementButton.click()
53 | ```
54 |
55 | ### 5. Create a screenshot
56 |
57 | ```swift
58 | let screenshot = app.screenshot()
59 | ```
60 |
61 | ### 5. (A) Use the quickExportWithTitle function on screenshot
62 |
63 | You can either use quickExportWithTitle directly on the XCUIScreenshot or follow step 6 and 7
64 |
65 | ### 6. Setup a Capturing View
66 |
67 | ```swift
68 | let exportSize = ExportSize.mac
69 | let capturingView = ScreenshotWithTitle(
70 | title: "Create your own image layouts.",
71 | image: screenshot.image,
72 | background: .color(.blue),
73 | exportSize: .mac
74 | )
75 | ```
76 |
77 | ### 7. Choose a location to export the image
78 |
79 | ```swift
80 | let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("result.png")
81 | exportMarketing(image: capturingView, toURL: url)
82 | ```
83 |
84 | ### 8. Clean up
85 |
86 | ```swift
87 | app.terminate()
88 | }
89 | }
90 | ```
91 |
92 | ### Full Code Example:
93 |
94 | ```swift
95 | import Blacksmith
96 | import XCTest
97 | import SwiftUI
98 |
99 | class ForgeUITests: XCTestCase {
100 | func testExample() throws {
101 | let app = XCUIApplication()
102 |
103 | app.launchArguments = ["-AppleLanguages", "(en)"]
104 | app.launch()
105 |
106 | let window = XCUIApplication().windows.firstMatch
107 | window.outlines.buttons["🛠 Layout Builder"].click()
108 |
109 | let addElementButton = window.toolbars.buttons["Add Element"]
110 | addElementButton.click()
111 |
112 | let screenshot = app.screenshot()
113 |
114 | let exportSize = ExportSize.mac
115 | let capturingView = ScreenshotWithTitle(
116 | title: "Create your own image layouts.",
117 | image: screenshot.image,
118 | background: .color(.blue),
119 | exportSize: .mac
120 | )
121 |
122 | let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("result.png")
123 | exportMarketing(image: capturingView, toURL: url)
124 |
125 | app.terminate()
126 | }
127 | }
128 | ```
129 |
--------------------------------------------------------------------------------
/Sources/Blacksmith/Helpers/ExportMarketingImageToURL.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ExportMarketingImageToURL.swift
3 | // Blacksmith
4 | //
5 | // Created by Florian Schweizer on 04.01.22.
6 | //
7 |
8 | import XCTest
9 | import SwiftUI
10 |
11 | public extension XCTestCase {
12 | #if os(macOS)
13 | func exportMarketing(image: V, toURL url: URL) {
14 | do {
15 | guard let nsImage = image.renderAsImage(),
16 | let representation = nsImage.tiffRepresentation else { return }
17 |
18 | guard let bitmap = NSBitmapImageRep(data: representation) else { return }
19 | let pngData = bitmap.representation(using: .png, properties: [:])
20 |
21 | try pngData?.write(to: url)
22 | print("Blacksmith: ☑️ Exported marketing image to \(url).")
23 | } catch {
24 | print(error)
25 | }
26 | }
27 | #elseif os(iOS)
28 | func createMarketing(image: V, exportSize: ExportSize) -> XCTAttachment {
29 | let uiImage = image.takeScreenshot(origin: .zero, size: exportSize.size)
30 |
31 | let attachment = XCTAttachment(image: uiImage)
32 | attachment.lifetime = .keepAlways
33 |
34 | return attachment
35 | }
36 | #endif
37 | }
38 |
39 | public extension XCUIScreenshot {
40 | #if os(macOS)
41 | func quickExportWithTitle(
42 | _ title: String,
43 | background: ImageBackground,
44 | exportSize: ExportSize,
45 | alignment: TitleAlignment,
46 | font: Font = .system(size: 50, weight: .regular, design: .rounded)
47 | ) {
48 | do {
49 | let image = ScreenshotWithTitle(
50 | title: title,
51 | image: Image(nsImage: self.image),
52 | background: background,
53 | exportSize: exportSize,
54 | alignment: alignment,
55 | font: font
56 | ).edgesIgnoringSafeArea(.all)
57 |
58 | guard let nsImage = image.renderAsImage(),
59 | let representation = nsImage.tiffRepresentation else { return }
60 |
61 | guard let bitmap = NSBitmapImageRep(data: representation) else { return }
62 | let pngData = bitmap.representation(using: .png, properties: [:])
63 |
64 | let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("\(title.replacingOccurrences(of: ".", with: "")).png")
65 |
66 | try pngData?.write(to: url)
67 | print("Blacksmith: ☑️ Exported marketing image to \(url).")
68 | } catch {
69 | print(error)
70 | }
71 | }
72 | #endif
73 |
74 | #if os(iOS)
75 | func quickExportWithTitle(
76 | _ title: String,
77 | background: ImageBackground,
78 | exportSize: ExportSize,
79 | alignment: TitleAlignment,
80 | font: Font = .system(size: 50, weight: .regular, design: .rounded)
81 | ) -> XCTAttachment? {
82 | let capturingView = ScreenshotWithTitle(
83 | title: title,
84 | image: Image(uiImage: self.image),
85 | background: background,
86 | exportSize: exportSize,
87 | alignment: alignment,
88 | font: font
89 | ).edgesIgnoringSafeArea(.all)
90 |
91 | let uiImage = capturingView.takeScreenshot(origin: .zero, size: exportSize.size)
92 |
93 | let attachment = XCTAttachment(image: uiImage)
94 | attachment.name = title
95 | attachment.lifetime = .keepAlways
96 |
97 | return attachment
98 | }
99 | #endif
100 | }
101 |
--------------------------------------------------------------------------------
/Sources/Blacksmith/Helpers/ExportSize.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ExportSize.swift
3 | // Blacksmith
4 | //
5 | // Created by Florian Schweizer on 03.01.22.
6 | //
7 |
8 | import SwiftUI
9 |
10 | public enum ExportSize {
11 | case iPhone_6_5_Inches
12 | case iPhone_5_5_Inches
13 |
14 | case iPadPro_12_9_Inches
15 |
16 | case mac
17 |
18 | case custom(CGSize, Double)
19 |
20 | public var size: CGSize {
21 | switch self {
22 | case .iPhone_6_5_Inches:
23 | return CGSize(width: 1242, height: 2688)
24 | case .iPhone_5_5_Inches:
25 | return CGSize(width: 1242, height: 2208)
26 |
27 | case .iPadPro_12_9_Inches:
28 | return CGSize(width: 2048, height: 2732)
29 |
30 | case .mac:
31 | return CGSize(width: 1280, height: 800)
32 |
33 | case .custom(let size, _):
34 | return size
35 | }
36 | }
37 |
38 | public var cornerRadius: Double {
39 | switch self {
40 | case .iPhone_6_5_Inches:
41 | return 20
42 | case .iPhone_5_5_Inches:
43 | return 24
44 |
45 | case .iPadPro_12_9_Inches:
46 | return 40
47 |
48 | case .mac:
49 | return 8
50 |
51 | case .custom(_, let radius):
52 | return radius
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Sources/Blacksmith/Helpers/ImageBackground.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ImageBackground.swift
3 | // Blacksmith
4 | //
5 | // Created by Florian Schweizer on 04.01.22.
6 | //
7 |
8 | import SwiftUI
9 |
10 | public enum ImageBackground {
11 | case color(Color)
12 | case gradient(LinearGradient)
13 | case image(Image)
14 | }
15 |
--------------------------------------------------------------------------------
/Sources/Blacksmith/Helpers/Localization.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Localization.swift
3 | //
4 | //
5 | // Created by Florian Schweizer on 05.01.22.
6 | //
7 |
8 | import Foundation
9 |
10 | public struct Localization {
11 | public let locale: String
12 | public let title: String
13 |
14 | public init(locale: String, title: String) {
15 | self.locale = locale
16 | self.title = title
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Sources/Blacksmith/Helpers/TitleAlignment.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TitleAlignment.swift
3 | // Blacksmith
4 | //
5 | // Created by Florian Schweizer on 04.01.22.
6 | //
7 |
8 | import Foundation
9 |
10 | public enum TitleAlignment {
11 | case titleAbove, titleBelow
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/Blacksmith/Helpers/ViewToNSImage.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ExportSwiftUIView.swift
3 | // Blacksmith
4 | //
5 | // Created by Florian Schweizer on 03.01.22.
6 | //
7 |
8 | import SwiftUI
9 |
10 | #if os(macOS)
11 | @available(macOS 10.15, *)
12 | public extension NSView {
13 | func bitmapImage() -> NSImage? {
14 | guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else {
15 | return nil
16 | }
17 |
18 | cacheDisplay(in: bounds, to: rep)
19 |
20 | guard let cgImage = rep.cgImage else {
21 | return nil
22 | }
23 |
24 | return NSImage(cgImage: cgImage, size: bounds.size)
25 | }
26 | }
27 |
28 | @available(macOS 10.15, *)
29 | public extension View {
30 | func renderAsImage() -> NSImage? {
31 | let view = NoInsetHostingView(rootView: self)
32 | view.setFrameSize(view.fittingSize)
33 |
34 | return view.bitmapImage()
35 | }
36 | }
37 |
38 | @available(macOS 10.15, *)
39 | public class NoInsetHostingView: NSHostingView where V: View {
40 | override public var safeAreaInsets: NSEdgeInsets {
41 | return .init()
42 | }
43 | }
44 | #endif
45 |
--------------------------------------------------------------------------------
/Sources/Blacksmith/Helpers/ViewToUIImage.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewToUIImage.swift
3 | // Blacksmith
4 | //
5 | // Created by Florian Schweizer on 04.01.22.
6 | //
7 |
8 | import SwiftUI
9 | #if canImport(UIKit)
10 | import UIKit
11 |
12 | extension View {
13 | func takeScreenshot(origin: CGPoint, size: CGSize) -> UIImage {
14 | let window = UIWindow(frame: CGRect(origin: origin, size: size))
15 |
16 | let hosting = UIHostingController(rootView: self)
17 |
18 | hosting.view.frame = window.frame
19 |
20 | window.addSubview(hosting.view)
21 | window.makeKeyAndVisible()
22 |
23 | return hosting.view.renderedImage
24 | }
25 | }
26 |
27 | extension UIView {
28 | var renderedImage: UIImage {
29 | let format = UIGraphicsImageRendererFormat()
30 | format.scale = 1
31 |
32 | let renderer = UIGraphicsImageRenderer(size: bounds.size, format: format)
33 | let pngData = renderer.pngData { context in
34 | self.drawHierarchy(in: bounds, afterScreenUpdates: true)
35 | }
36 |
37 | return UIImage(data: pngData) ?? UIImage(systemName: "exclamationmark.triangle.fill")!
38 | }
39 | }
40 | #endif
41 |
--------------------------------------------------------------------------------
/Sources/Blacksmith/Views/CapturingView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CapturingView.swift
3 | // Blacksmith
4 | //
5 | // Created by Florian Schweizer on 04.01.22.
6 | //
7 |
8 | import Foundation
9 |
10 | public protocol CapturingView {
11 | var exportSize: ExportSize { get }
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/Blacksmith/Views/ScreenshotWithTitle.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ScreenshotWithTitle.swift
3 | // Blacksmith
4 | //
5 | // Created by Florian Schweizer on 03.01.22.
6 | //
7 |
8 | import SwiftUI
9 |
10 | public struct ScreenshotWithTitle: View, CapturingView {
11 | public let title: String
12 | public let image: Image
13 | public let background: ImageBackground
14 | public let exportSize: ExportSize
15 | public let alignment: TitleAlignment
16 | public let font: Font
17 | public let foregroundColor: Color
18 |
19 | public init(
20 | title: String,
21 | image: Image,
22 | background: ImageBackground,
23 | exportSize: ExportSize,
24 | alignment: TitleAlignment = .titleAbove,
25 | font: Font = .system(size: 50, weight: .regular, design: .rounded),
26 | foregroundColor: Color = .primary
27 | ) {
28 | self.title = title
29 | self.image = image
30 | self.background = background
31 | self.exportSize = exportSize
32 | self.alignment = alignment
33 | self.font = font
34 | self.foregroundColor = foregroundColor
35 | }
36 |
37 | public var body: some View {
38 | ZStack {
39 | switch background {
40 | case .color(let color):
41 | color
42 | case .gradient(let linearGradient):
43 | linearGradient
44 | case .image(let image):
45 | image
46 | .resizable()
47 | .scaledToFill()
48 | .frame(width: exportSize.size.width, height: exportSize.size.height)
49 | .clipped()
50 | }
51 |
52 | VStack {
53 | if case .titleAbove = alignment {
54 | Text(title)
55 | .font(font)
56 | .padding(.top)
57 | .foregroundColor(foregroundColor)
58 |
59 | Spacer()
60 | }
61 |
62 | image
63 | .resizable()
64 | .cornerRadius(exportSize.cornerRadius)
65 | .scaledToFit()
66 | .padding()
67 |
68 | if case .titleBelow = alignment {
69 | Spacer()
70 |
71 | Text(title)
72 | .font(font)
73 | .padding(.bottom)
74 | .foregroundColor(foregroundColor)
75 | }
76 | }
77 | .padding()
78 | }
79 | .frame(width: exportSize.size.width, height: exportSize.size.height)
80 | }
81 | }
82 |
--------------------------------------------------------------------------------