├── .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 | ![Framed Example Screenshots](example.png) 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 --------------------------------------------------------------------------------