├── .gitignore
├── .swiftpm
└── xcode
│ └── xcshareddata
│ └── xcschemes
│ └── easy-frame.xcscheme
├── Package.resolved
├── Package.swift
├── README.md
├── Sources
├── EasyFrameCommand
│ ├── EasyFrameCommand.swift
│ ├── Extension
│ │ └── NSImage+PixelSize.swift
│ ├── Model
│ │ ├── EasyFrameConfig.swift
│ │ ├── FrameViewModel.swift
│ │ ├── Layout.swift
│ │ ├── ScreenshotViewModel.swift
│ │ └── SupportedDevice.swift
│ └── View
│ │ ├── CustomBackgroundView.swift
│ │ ├── DeviceFrameView.swift
│ │ └── ScreenshotView.swift
└── Resources
│ ├── Apple iPad Pro (11-inch) Space Gray.png
│ ├── Apple iPad Pro (12.9-inch) (4th generation) Space Gray.png
│ ├── Apple iPhone 14 Pro Black.png
│ ├── Apple iPhone 14 Pro Max Black.png
│ ├── Apple iPhone 8 Plus Space Gray.png
│ └── Apple iPhone SE Black.png
└── example.png
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/easy-frame.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
9 |
10 |
16 |
22 |
23 |
24 |
25 |
26 |
32 |
33 |
43 |
45 |
51 |
52 |
53 |
54 |
60 |
62 |
68 |
69 |
70 |
71 |
73 |
74 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "ee29e6db266acb1b98ecddab812d754c576f4903e80ae71d02005e35eb4901b5",
3 | "pins" : [
4 | {
5 | "identity" : "swift-argument-parser",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/apple/swift-argument-parser",
8 | "state" : {
9 | "revision" : "41982a3656a71c768319979febd796c6fd111d5c",
10 | "version" : "1.5.0"
11 | }
12 | }
13 | ],
14 | "version" : 3
15 | }
16 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
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: "easy-frame",
8 | platforms: [.macOS(.v15)],
9 | dependencies: [
10 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"),
11 | ],
12 | targets: [
13 | .executableTarget(
14 | name: "easy-frame",
15 | dependencies: [
16 | .product(name: "ArgumentParser", package: "swift-argument-parser")
17 | ],
18 | resources: [.process("Resources")]
19 | )
20 | ],
21 | swiftLanguageModes: [.v5]
22 | )
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # EasyFrameCommand
2 | `easy-frame` is a lightweight Swift command-line tool to create fully customizable framed App Store screenshots using SwiftUI. Built for automation, this open-source Swift package efficiently frames screenshots for all localizations and devices. While it follows a workflow similar to fastlane’s frameit, you are in full controll. Simply fork the reporsitory with just about 500 lines of swift code and get as creative as you desire.
3 |
4 | 
5 |
6 | ## Who is this tool for?
7 | This Swift package is ideal for indie Swift developers who value:
8 | - **Automation** over manual screenshot creation
9 | - **Full design control** over relying on premade templates with limited customization options
10 | - **A free and open-source solution** over paid alternatives
11 |
12 | ## Getting started
13 | To generate screenshots like in the example above - using the implementation as-is, just follow the below steps:
14 | 1. Set up the directory structure as shown in the example below. Place your unframed raw screenshots in the `raw-screenshots` directory, organized by locale.
15 | ```
16 | parent-folder/
17 | raw-screenshots/
18 | EasyFrame.json
19 | de-DE/
20 | iPhone 15 Pro Max-1-calendar.png
21 | iPhone 15 Pro Max-2-list.png
22 | iPhone 15 Pro Max-3-add-entry.png
23 | iPad Pro (12.9-inch) (6th generation)-1-calendar.png
24 | iPad Pro (12.9-inch) (6th generation)-2-list.png
25 | iPad Pro (12.9-inch) (6th generation)-3-add-entry.png
26 | en-GB/
27 | iPhone 15 Pro Max-1-calendar.png
28 | iPhone 15 Pro Max-2-list.png
29 | iPhone 15 Pro Max-3-add-entry.png
30 | iPad Pro (12.9-inch) (6th generation)-1-calendar.png
31 | iPad Pro (12.9-inch) (6th generation)-2-list.png
32 | iPad Pro (12.9-inch) (6th generation)-3-add-entry.png
33 | ```
34 | - If you use Fastlane to generate unframed screenshots, its output directory structure already meets `easy-frame`'s requirements. Just ensure that the `output_directory` is set to `raw-screenshots`.
35 | ```ruby
36 | capture_ios_screenshots(
37 | devices: ["iPhone 15 Pro Max", "iPad Pro (12.9-inch) (6th generation)"],
38 | languages: ["de-DE", "en-GB"],
39 | scheme: ENV["XCODE_SCHEME_UI_TEST"],
40 | output_directory: "./fastlane/raw-screenshots"
41 | )
42 | ```
43 | 1. Create an `EasyFrame.json` configuration file based on the below [Example EasyFrame.json](#example-easyframejson). The config defines each App Store page with:
44 | - localized texts, which will be displayed on the framed screenshot
45 | - a screenshot name, which must partially match the raw screenshot filenames
46 | 1. Run the local `easy-frame` swift package command to generate framed screenshots into the `parent-folder/screenshots/` directory:
47 | ```sh
48 | cd path/to/EasyFrameCommandProject
49 | swift run easy-frame path/to/parent-folder
50 | ```
51 |
52 | ## Which devices are supported?
53 | See [Sources/EasyFrameCommand/Model/SupportedDevice.swift](Sources/EasyFrameCommand/Model/SupportedDevice.swift). The device frames have been taken from https://github.com/fastlane/frameit-frames.
54 |
55 | ## Where to adjust the SwiftUI layout?
56 | See [Sources/EasyFrameCommand/View/ScreenshotView.swift](Sources/EasyFrameCommand/View/ScreenshotView.swift)
57 |
58 | ## Example directory structure
59 | ```
60 | parent-folder/
61 | raw-screenshots/ (the locale folders contains the unframed raw screenshots)
62 | EasyFrame.json
63 | de-DE/
64 | iPhone 15 Pro Max-1-calendar.png
65 | iPhone 15 Pro Max-2-list.png
66 | iPhone 15 Pro Max-3-add-entry.png
67 | iPad Pro (12.9-inch) (6th generation)-1-calendar.png
68 | iPad Pro (12.9-inch) (6th generation)-2-list.png
69 | iPad Pro (12.9-inch) (6th generation)-3-add-entry.png
70 | en-GB/
71 | iPhone 15 Pro Max-1-calendar.png
72 | iPhone 15 Pro Max-2-list.png
73 | iPhone 15 Pro Max-3-add-entry.png
74 | iPad Pro (12.9-inch) (6th generation)-1-calendar.png
75 | iPad Pro (12.9-inch) (6th generation)-2-list.png
76 | iPad Pro (12.9-inch) (6th generation)-3-add-entry.png
77 | screenshots/ (this folder will be generated by easy-frame with the framed screenshots)
78 | de-DE/
79 | iPhone 15 Pro Max-1-calendar.jpg
80 | iPhone 15 Pro Max-2-list.jpg
81 | iPhone 15 Pro Max-3-add-entry.jpg
82 | iPad Pro (12.9-inch) (6th generation)-1-calendar.jpg
83 | iPad Pro (12.9-inch) (6th generation)-2-list.jpg
84 | iPad Pro (12.9-inch) (6th generation)-3-add-entry.jpg
85 | en-GB/
86 | iPhone 15 Pro Max-1-calendar.jpg
87 | iPhone 15 Pro Max-2-list.jpg
88 | iPhone 15 Pro Max-3-add-entry.jpg
89 | iPad Pro (12.9-inch) (6th generation)-1-calendar.jpg
90 | iPad Pro (12.9-inch) (6th generation)-2-list.jpg
91 | iPad Pro (12.9-inch) (6th generation)-3-add-entry.jpg
92 | ```
93 |
94 | ## Example EasyFrame.json
95 | ```json
96 | {
97 | "pages": [
98 | {
99 | "languages": [
100 | { "locale": "en-GB", "title": "Title 1 EN", "description": "Description" },
101 | { "locale": "de-DE", "title": "Title 1 DE", "description": "Description" }
102 | ],
103 | "screenshot": "1-calendar"
104 | },
105 | {
106 | "languages": [
107 | { "locale": "en-GB", "title": "Title 2 EN", "description": "Description" },
108 | { "locale": "de-DE", "title": "Title 2 DE", "description": "Description" }
109 | ],
110 | "screenshot": "2-list"
111 | },
112 | {
113 | "languages": [
114 | { "locale": "en-GB", "title": "Title 3 EN", "description": "Description" },
115 | { "locale": "de-DE", "title": "Title 3 DE", "description": "Description" }
116 | ],
117 | "screenshot": "3-add-entry"
118 | }
119 | ]
120 | }
121 | ```
122 |
123 | ## Acknowledgments
124 | Thanks to [FrameKit](https://github.com/ainame/FrameKit) and fastlane [frameit](https://docs.fastlane.tools/actions/frameit/), which both inspired me and made this swift package possible.
125 |
126 | ## Roadmap
127 | There is no planned roadmap or a guarantee of ongoing maintenance. This Swift package is intended to be forked and serves as a solid starting point for further customization 😉
128 |
--------------------------------------------------------------------------------
/Sources/EasyFrameCommand/EasyFrameCommand.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DeviceFrameView.swift
3 | // easy-frame
4 | //
5 | // Created by Alexander Schmutz on 27.01.25.
6 | //
7 |
8 | import SwiftUI
9 | import ArgumentParser
10 |
11 | @main
12 | struct EasyFrameCommand: AsyncParsableCommand {
13 | static var configuration: CommandConfiguration {
14 | CommandConfiguration(commandName: "easy-frame")
15 | }
16 |
17 | @Argument(
18 | help: "An absolute or relative path to the parent folder, which contains the EasyFrame.json file and a raw-screeshots folder with individual locale folders",
19 | completion: .file()
20 | )
21 | var rootFolder: String
22 |
23 | @MainActor
24 | mutating func run() async throws {
25 | let rootFolderURL = URL(fileURLWithPath: rootFolder)
26 | let rawScreenshotsFolderURL = rootFolderURL.appendingPathComponent("raw-screenshots")
27 | let outputFolderURL = rootFolderURL.appendingPathComponent("screenshots")
28 |
29 | let easyFrameConfig = try getEasyFrameConfig(rawScreenshotsFolderURL: rawScreenshotsFolderURL)
30 |
31 | try easyFrameConfig.pages.enumerated().forEach { pageIndex, page in
32 | try page.languages.forEach { language in
33 |
34 | let screenshotsLocaleFolderURL = rawScreenshotsFolderURL.appendingPathComponent(language.locale)
35 | let outputFolderURL = outputFolderURL.appendingPathComponent(language.locale)
36 |
37 | switch page.type {
38 | case .default(let screenshot):
39 | try createDefaultScreenshotPage(
40 | pageIndex: pageIndex,
41 | language: language,
42 | screenshotsLocaleFolderURL: screenshotsLocaleFolderURL,
43 | outputFolderURL: outputFolderURL,
44 | screenshot: screenshot
45 | )
46 | }
47 | }
48 | }
49 | }
50 |
51 | @MainActor
52 | private func createDefaultScreenshotPage(
53 | pageIndex: Int,
54 | language: LanguageConfig,
55 | screenshotsLocaleFolderURL: URL,
56 | outputFolderURL: URL,
57 | screenshot: String
58 | ) throws {
59 | try FileManager.default
60 | .contentsOfDirectory(at: screenshotsLocaleFolderURL, includingPropertiesForKeys: nil, options: [])
61 | .filter { url in
62 | url.lastPathComponent.contains(screenshot)
63 | }
64 | .forEach { screenshot in
65 | let screenshotImage = try getNSImage(fromPath: screenshot.relativePath)
66 | let layout = try getDeviceLayout(pixelSize: screenshotImage.pixelSize)
67 | let frameImage = try getFrameImage(from: layout)
68 |
69 | let frameViewModel = FrameViewModel(
70 | screenshotImage: screenshotImage,
71 | frameImage: frameImage,
72 | screenshotCornerRadius: layout.cornerRadius,
73 | frameOffset: layout.deviceFrameOffset
74 | )
75 | let deviceFrameView = DeviceFrameView(viewModel: frameViewModel)
76 | let framedScreenshot = try getNSImage(fromView: deviceFrameView, size: frameImage.size)
77 |
78 | let screenshotViewModel = ScreenshotViewModel(
79 | pageIndex: pageIndex,
80 | title: language.title,
81 | description: language.description,
82 | framedScreenshots: [framedScreenshot]
83 | )
84 |
85 | let screenshotView = ScreenshotView(
86 | layout: layout,
87 | viewModel: screenshotViewModel,
88 | locale: language.locale
89 | )
90 | let nsImage = try getNSImage(fromView: screenshotView, size: layout.screenshotSize)
91 |
92 | try FileManager.default.createDirectory(at: outputFolderURL, withIntermediateDirectories: true)
93 | let fileName = screenshot
94 | .deletingPathExtension()
95 | .appendingPathExtension("jpg")
96 | .lastPathComponent
97 | let outputFileURL = outputFolderURL.appendingPathComponent(fileName)
98 | try saveFile(nsImage: nsImage, outputPath: outputFileURL.relativePath)
99 | }
100 | }
101 |
102 | private func getEasyFrameConfig(rawScreenshotsFolderURL: URL) throws -> EasyFrameConfig {
103 | let easyFrameJsonFileURL = rawScreenshotsFolderURL.appendingPathComponent("EasyFrame.json")
104 | let data = try Data(contentsOf: easyFrameJsonFileURL)
105 | return try JSONDecoder().decode(EasyFrameConfig.self, from: data)
106 | }
107 |
108 | private func saveFile(nsImage: NSImage, outputPath: String) throws {
109 | guard let jpegData = jpegDataFrom(image: nsImage) else {
110 | throw EasyFrameError.imageOperationFailure("Error: can't generate image from view")
111 | }
112 |
113 | let result = FileManager.default.createFile(atPath: outputPath, contents: jpegData)
114 | guard result else {
115 | throw EasyFrameError.fileSavingFailure("Error: can't save generated image at \(outputPath)")
116 | }
117 | }
118 |
119 | private func jpegDataFrom(image: NSImage) -> Data? {
120 | let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil)!
121 | let bitmapRep = NSBitmapImageRep(cgImage: cgImage)
122 | return bitmapRep.representation(using: .jpeg, properties: [:])
123 | }
124 |
125 | private func getNSImage(fromPath path: String) throws -> NSImage {
126 | let absolutePath = URL(fileURLWithPath: NSString(string: path).expandingTildeInPath).path
127 | guard let deviceFrameImage = NSImage(contentsOfFile: absolutePath) else {
128 | throw EasyFrameError.fileNotFound("device frame was not found at \(path)")
129 | }
130 | return deviceFrameImage
131 | }
132 |
133 | @MainActor
134 | private func getNSImage(fromView view: Content, size: CGSize) throws -> NSImage {
135 | let renderer = ImageRenderer(content: view)
136 | renderer.proposedSize = .init(size)
137 | renderer.scale = 1.0
138 | guard let nsImage = renderer.nsImage else {
139 | throw EasyFrameError.imageOperationFailure("Error: can't generate image from view")
140 | }
141 | return nsImage
142 | }
143 |
144 | private func getDeviceLayout(pixelSize: CGSize) throws -> Layout {
145 | guard let matchingDevice = SupportedDevice.allCases.first(where: { device in
146 | device.value.screenshotSize == pixelSize
147 | }) else {
148 | throw EasyFrameError.deviceFrameNotSupported("No matching device frame found for pixelSize \(pixelSize)")
149 | }
150 | return matchingDevice.value
151 | }
152 |
153 | private func getFrameImage(from layout: Layout) throws -> NSImage {
154 | let url = Bundle.module.url(forResource: layout.frameImageName, withExtension: nil)!
155 | return NSImage(contentsOf: url)!
156 | }
157 |
158 | enum EasyFrameError: Error {
159 | case fileNotFound(String)
160 | case imageOperationFailure(String)
161 | case fileSavingFailure(String)
162 | case deviceFrameNotSupported(String)
163 | }
164 | }
165 |
--------------------------------------------------------------------------------
/Sources/EasyFrameCommand/Extension/NSImage+PixelSize.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSImage+PixelSize.swift
3 | // easy-frame
4 | //
5 | // Created by Alexander Schmutz on 30.01.25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | extension NSImage {
11 |
12 | var pixelSize: CGSize {
13 | let representation = representations[0]
14 | return CGSize(width: representation.pixelsWide, height: representation.pixelsHigh)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Sources/EasyFrameCommand/Model/EasyFrameConfig.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ScreensConfiguration.swift
3 | // easy-frame
4 | //
5 | // Created by Alexander Schmutz on 27.01.25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct EasyFrameConfig: Decodable {
11 | let pages: [PageConfig]
12 | }
13 |
14 | struct PageConfig: Decodable {
15 | let languages: [LanguageConfig]
16 | let type: PageType
17 |
18 | enum CodingKeys: CodingKey {
19 | case languages
20 | case screenshot
21 | }
22 |
23 | init(from decoder: any Decoder) throws {
24 | let container = try decoder.container(keyedBy: CodingKeys.self)
25 | self.languages = try container.decode([LanguageConfig].self, forKey: .languages)
26 | if let screenshot = try? container.decode(String.self, forKey: .screenshot) {
27 | self.type = .default(screenshot: screenshot)
28 | } else {
29 | throw PageConfigError.invalidConfiguration
30 | }
31 | }
32 |
33 | enum PageConfigError: Error {
34 | case invalidConfiguration
35 | }
36 | }
37 |
38 | struct LanguageConfig: Decodable {
39 | let locale: String
40 | let title: String
41 | let description: String
42 | }
43 |
44 | enum PageType: Decodable {
45 | case `default`(screenshot: String)
46 | }
47 |
--------------------------------------------------------------------------------
/Sources/EasyFrameCommand/Model/FrameViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FrameViewModel.swift
3 | // easy-frame
4 | //
5 | // Created by Alexander Schmutz on 27.01.25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct FrameViewModel {
11 | let screenshotImage: Image
12 | let screenshotSize: CGSize
13 | let screenshotCornerRadius: CGFloat
14 |
15 | let frameImage: Image
16 | let frameSize: CGSize
17 | let frameOffset: CGSize
18 |
19 | init(
20 | screenshotImage: NSImage,
21 | frameImage: NSImage,
22 | screenshotCornerRadius: CGFloat,
23 | frameOffset: CGSize
24 | ) {
25 | self.screenshotImage = Image(nsImage: screenshotImage)
26 | self.screenshotSize = screenshotImage.pixelSize
27 | self.screenshotCornerRadius = screenshotCornerRadius
28 |
29 | self.frameImage = Image(nsImage: frameImage)
30 | self.frameSize = frameImage.pixelSize
31 | self.frameOffset = frameOffset
32 | }
33 | }
34 |
35 |
--------------------------------------------------------------------------------
/Sources/EasyFrameCommand/Model/Layout.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DeviceFrameView.swift
3 | // easy-frame
4 | //
5 | // Created by Alexander Schmutz on 27.01.25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct Layout {
11 | let frameImageName: String
12 | let screenshotSize: CGSize
13 | let deviceFrameOffset: CGSize
14 | let cornerRadius: CGFloat
15 |
16 | func relative(_ value: CGFloat) -> CGFloat {
17 | screenshotSize.height / 2796 * value
18 | }
19 |
20 | static let iPhone15ProMax = Self(
21 | frameImageName: "Apple iPhone 14 Pro Max Black.png",
22 | screenshotSize: CGSize(width: 1290, height: 2796),
23 | deviceFrameOffset: .init(width: 0, height: 2),
24 | cornerRadius: 35
25 | )
26 |
27 | static let iPadPro = Self(
28 | frameImageName: "Apple iPad Pro (12.9-inch) (4th generation) Space Gray.png",
29 | screenshotSize: CGSize(width: 2048, height: 2732),
30 | deviceFrameOffset: .init(width: 2, height: 0),
31 | cornerRadius: 35
32 | )
33 |
34 | static let iPhoneSE3rdGen = Self(
35 | frameImageName: "Apple iPhone SE Black.png",
36 | screenshotSize: CGSize(width: 750, height: 1334),
37 | deviceFrameOffset: .init(width: 0, height: 2),
38 | cornerRadius: 0
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/Sources/EasyFrameCommand/Model/ScreenshotViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ScreenshotViewModel.swift
3 | // easy-frame
4 | //
5 | // Created by Alexander Schmutz on 27.01.25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ScreenshotViewModel {
11 | let pageIndex: Int
12 | let title: String
13 | let description: String
14 | let framedScreenshots: [Image]
15 |
16 | init(
17 | pageIndex: Int,
18 | title: String,
19 | description: String,
20 | framedScreenshots: [NSImage]
21 | ) {
22 | self.pageIndex = pageIndex
23 | self.title = title
24 | self.description = description
25 | self.framedScreenshots = framedScreenshots.map { Image(nsImage: $0) }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Sources/EasyFrameCommand/Model/SupportedDevice.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SupportedDevice.swift
3 | // easy-frame
4 | //
5 | // Created by Alexander Schmutz on 27.01.25.
6 | //
7 |
8 | import Foundation
9 |
10 | enum SupportedDevice: CaseIterable {
11 | case iPhone15ProMax
12 | case iPadPro
13 | case iPhoneSE3rdGen
14 |
15 | var value: Layout {
16 | switch self {
17 | case .iPhone15ProMax: .iPhone15ProMax
18 | case .iPadPro: .iPadPro
19 | case .iPhoneSE3rdGen: .iPhoneSE3rdGen
20 | }
21 | }
22 | }
23 |
24 |
--------------------------------------------------------------------------------
/Sources/EasyFrameCommand/View/CustomBackgroundView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomBackgroundView.swift
3 | // easy-frame
4 | //
5 | // Created by Alexander Schmutz on 02.02.25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct CustomBackgroundView: View {
11 | let pageIndex: Int
12 |
13 | var body: some View {
14 | MeshGradient(width: 3, height: 3, points: [
15 | .init(0, 0), .init(0.5, 0), .init(1, 0),
16 | .init(0, 0.5), .init(0.5, 0.5), .init(1, 0.5),
17 | .init(0, 1), .init(0.5, 1), .init(1, 1)
18 | ], colors: colors)
19 | }
20 |
21 | let orange = Color(red: 251 / 255, green: 133 / 255, blue: 0 / 255)
22 | let blue = Color(red: 30 / 255, green: 85 / 255, blue: 125 / 255)
23 | let purple = Color.purple
24 |
25 | var colors: [Color] {
26 | if pageIndex % 3 == 0 {
27 | return [
28 | purple, purple, blue,
29 | orange, orange, purple,
30 | orange, orange, orange
31 | ]
32 | } else if pageIndex % 3 == 1 {
33 | return [
34 | blue, purple, purple,
35 | purple, orange, blue,
36 | orange, orange, orange
37 | ]
38 | } else {
39 | return [
40 | purple, purple, purple,
41 | blue, orange, orange,
42 | orange, orange, orange
43 | ]
44 | }
45 | }
46 | }
47 |
48 | #Preview {
49 | CustomBackgroundView(pageIndex: 0)
50 | }
51 |
--------------------------------------------------------------------------------
/Sources/EasyFrameCommand/View/DeviceFrameView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DeviceFrameView.swift
3 | // easy-frame
4 | //
5 | // Created by Alexander Schmutz on 27.01.25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct DeviceFrameView: View {
11 | let viewModel: FrameViewModel
12 |
13 | var body: some View {
14 | ZStack {
15 | viewModel.screenshotImage
16 | .resizable()
17 | .frame(width: viewModel.screenshotSize.width, height: viewModel.screenshotSize.height)
18 | .clipShape(RoundedRectangle(cornerRadius: viewModel.screenshotCornerRadius))
19 |
20 | viewModel.frameImage
21 | .resizable()
22 | .frame(width: viewModel.frameSize.width, height: viewModel.frameSize.height)
23 | .offset(viewModel.frameOffset)
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/EasyFrameCommand/View/ScreenshotView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ScreenshotView.swift
3 | // easy-frame
4 | //
5 | // Created by Alexander Schmutz on 27.01.25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ScreenshotView: View {
11 | let layout: Layout
12 | let viewModel: ScreenshotViewModel
13 | let locale: String
14 |
15 | var body: some View {
16 | ZStack {
17 | CustomBackgroundView(pageIndex: viewModel.pageIndex)
18 |
19 | VStack(spacing: 0) {
20 | VStack(spacing: layout.relative(37)) {
21 | let titleFontSize: CGFloat = 110
22 | Text(viewModel.title)
23 | .font(.system(size: layout.relative(titleFontSize)))
24 | .lineSpacing(layout.relative(titleFontSize) / 7)
25 | .kerning(layout.relative(titleFontSize) / 30)
26 | .fontWeight(.bold)
27 |
28 | if !viewModel.description.isEmpty {
29 | let descriptionFontSize: CGFloat = 75
30 | Text(viewModel.description)
31 | .font(.system(size: layout.relative(descriptionFontSize)))
32 | .lineSpacing(layout.relative(descriptionFontSize) / 7)
33 | .kerning(layout.relative(descriptionFontSize) / 30)
34 | .fontWeight(.light)
35 | .padding(.horizontal, layout.relative(35))
36 | }
37 | }
38 | .foregroundStyle(.white)
39 | .multilineTextAlignment(.center)
40 | .padding(.horizontal, layout.relative(10))
41 | .frame(height: titleFrameHeight)
42 |
43 | viewModel.framedScreenshots[0]
44 | .resizable()
45 | .scaledToFit()
46 | .shadow(radius: layout.relative(20))
47 | .padding(.bottom, layout.relative(80))
48 | .frame(height: screenshotFrameHeight)
49 | }
50 | }
51 | .environment(\.locale, Locale(identifier: locale))
52 | }
53 |
54 | private var titleFrameHeight: CGFloat {
55 | layout.screenshotSize.height * 0.2
56 | }
57 |
58 | private var screenshotFrameHeight: CGFloat {
59 | layout.screenshotSize.height - titleFrameHeight
60 | }
61 | }
62 |
63 | #Preview {
64 | ScreenshotView(
65 | layout: .iPhone15ProMax,
66 | viewModel: ScreenshotViewModel(
67 | pageIndex: 0,
68 | title: "My title",
69 | description: "My description",
70 | framedScreenshots: []
71 | ),
72 | locale: "en-GB"
73 | )
74 | }
75 |
--------------------------------------------------------------------------------
/Sources/Resources/Apple iPad Pro (11-inch) Space Gray.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alschmut/EasyFrameCommand/b15b00151d96cba8825d6db64b195f67827b6372/Sources/Resources/Apple iPad Pro (11-inch) Space Gray.png
--------------------------------------------------------------------------------
/Sources/Resources/Apple iPad Pro (12.9-inch) (4th generation) Space Gray.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alschmut/EasyFrameCommand/b15b00151d96cba8825d6db64b195f67827b6372/Sources/Resources/Apple iPad Pro (12.9-inch) (4th generation) Space Gray.png
--------------------------------------------------------------------------------
/Sources/Resources/Apple iPhone 14 Pro Black.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alschmut/EasyFrameCommand/b15b00151d96cba8825d6db64b195f67827b6372/Sources/Resources/Apple iPhone 14 Pro Black.png
--------------------------------------------------------------------------------
/Sources/Resources/Apple iPhone 14 Pro Max Black.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alschmut/EasyFrameCommand/b15b00151d96cba8825d6db64b195f67827b6372/Sources/Resources/Apple iPhone 14 Pro Max Black.png
--------------------------------------------------------------------------------
/Sources/Resources/Apple iPhone 8 Plus Space Gray.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alschmut/EasyFrameCommand/b15b00151d96cba8825d6db64b195f67827b6372/Sources/Resources/Apple iPhone 8 Plus Space Gray.png
--------------------------------------------------------------------------------
/Sources/Resources/Apple iPhone SE Black.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alschmut/EasyFrameCommand/b15b00151d96cba8825d6db64b195f67827b6372/Sources/Resources/Apple iPhone SE Black.png
--------------------------------------------------------------------------------
/example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/alschmut/EasyFrameCommand/b15b00151d96cba8825d6db64b195f67827b6372/example.png
--------------------------------------------------------------------------------