├── Sources ├── FrameKit │ ├── LayoutProviderOption.swift │ ├── LayoutProvider.swift │ ├── StoreScreenshotView.swift │ ├── Helper.swift │ ├── CustomFontLoader.swift │ ├── DeviceFrameView.swift │ ├── StoreScreenshotRenderer.swift │ ├── DeviceFrame.swift │ └── TextWrap.swift ├── SampleFrameKitLayout │ ├── StoreScreenshotView+DefaultLayout.swift │ ├── SampleContent.swift │ ├── SampleLayout.swift │ ├── SampleStoreScreenshotView.swift │ ├── SampleHeroStoreScreenshotView.swift │ └── SampleLayout+Portrait.swift └── SampleFrameKitCLI │ ├── SampleLayoutOption.swift │ └── Command.swift ├── Tests └── FrameKitTests │ └── TextWrapTests.swift ├── Package.resolved ├── LICENSE ├── .github └── workflows │ └── pages_docc.yml ├── Package.swift ├── .gitignore └── README.md /Sources/FrameKit/LayoutProviderOption.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol LayoutProviderOption: CaseIterable, RawRepresentable where RawValue == String { 4 | associatedtype Layout: LayoutProvider 5 | var value: Layout { get } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/FrameKit/LayoutProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | // This is the minimal requirement to be able to work with `Frameit.run` 5 | public protocol LayoutProvider { 6 | var size: CGSize { get } 7 | var deviceFrameOffset: CGSize { get } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/FrameKit/StoreScreenshotView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public protocol StoreScreenshotView: View { 4 | associatedtype Layout: LayoutProvider 5 | associatedtype Content 6 | 7 | var layout: Layout { get } 8 | var content: Content { get } 9 | static func makeView(layout: Layout, content: Content) -> Self 10 | } 11 | -------------------------------------------------------------------------------- /Sources/SampleFrameKitLayout/StoreScreenshotView+DefaultLayout.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import FrameKit 4 | 5 | extension StoreScreenshotView where Self.Layout == SampleLayout { 6 | var keywordFont: Font { Font.system(size: layout.keywordFontSize, weight: .bold, design: .default) } 7 | var titleFont: Font { Font.system(size: layout.titleFontSize, weight: .regular, design: .default) } 8 | } 9 | -------------------------------------------------------------------------------- /Tests/FrameKitTests/TextWrapTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import FrameKit 3 | 4 | final class TextWrapTests: XCTestCase { 5 | func test_two_lines() throws { 6 | XCTAssertEqual(TextWrap.wrapTextEqually("Tu app de recetas de cocina", into: 2), "Tu app de\nrecetas de cocina") 7 | } 8 | 9 | func test_tokenize_sentenceIncludingPunctuation() throws { 10 | XCTAssertEqual(TextWrap.wrapTextEqually("私は、元気です", into: 2, using: ""), "私は、\n元気です") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/SampleFrameKitLayout/SampleContent.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | public struct SampleContent { 4 | public let locale: Locale 5 | public let keyword: String 6 | public let title: String 7 | public let backgroundImage: NSImage? 8 | public let framedScreenshots: [NSImage] 9 | 10 | public init(locale: Locale, keyword: String, title: String, backgroundImage: NSImage? = nil, framedScreenshots: [NSImage]) { 11 | self.locale = locale 12 | self.keyword = keyword 13 | self.title = title 14 | self.backgroundImage = backgroundImage 15 | self.framedScreenshots = framedScreenshots 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/FrameKit/Helper.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | // To expand a file path written as relative path 4 | public func absolutePath(_ relativePath: String) -> String { 5 | URL(fileURLWithPath: NSString(string: relativePath).expandingTildeInPath).path 6 | } 7 | 8 | func convertToImage(view: NSView, format: NSBitmapImageRep.FileType) -> Data? { 9 | guard let bitmapRepresentation = view.bitmapImageRepForCachingDisplay(in: view.frame) else { 10 | return nil 11 | } 12 | bitmapRepresentation.pixelsHigh = Int(view.frame.size.height) 13 | bitmapRepresentation.pixelsWide = Int(view.frame.size.width) 14 | bitmapRepresentation.size = view.frame.size 15 | view.cacheDisplay(in: view.frame, to: bitmapRepresentation) 16 | return bitmapRepresentation.representation(using: format, properties: [:]) 17 | } 18 | -------------------------------------------------------------------------------- /Sources/FrameKit/CustomFontLoader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreText 3 | 4 | public class CustomFontLoader { 5 | public struct FontEntry: Hashable { 6 | let fileURL: URL 7 | let fontName: String 8 | } 9 | 10 | public static var shared = CustomFontLoader() 11 | 12 | private var registeredFonts: Set = [] 13 | 14 | public func registerFont(at url: URL, with fontName: String) throws { 15 | let entry = FontEntry(fileURL: url, fontName: fontName) 16 | if registeredFonts.contains(entry) { 17 | return 18 | } 19 | 20 | var error: Unmanaged? 21 | 22 | CTFontManagerRegisterFontsForURL(entry.fileURL as CFURL, .process, &error) 23 | 24 | if let error = error?.takeRetainedValue() { 25 | throw error 26 | } 27 | 28 | registeredFonts.insert(entry) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-algorithms", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-algorithms", 7 | "state" : { 8 | "revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e", 9 | "version" : "1.0.0" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-argument-parser", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/apple/swift-argument-parser", 16 | "state" : { 17 | "revision" : "6b2aa2748a7881eebb9f84fb10c01293e15b52ca", 18 | "version" : "0.5.0" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-numerics", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/apple/swift-numerics", 25 | "state" : { 26 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 27 | "version" : "1.0.2" 28 | } 29 | } 30 | ], 31 | "version" : 2 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Satoshi Namai 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 | -------------------------------------------------------------------------------- /Sources/SampleFrameKitCLI/SampleLayoutOption.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import SampleFrameKitLayout 3 | import FrameKit 4 | 5 | public enum SampleLayoutOption: String, RawRepresentable, ExpressibleByArgument, LayoutProviderOption { 6 | case iPhone65 = "iphone_65" 7 | case iPhone55 = "iphone_55" 8 | case iPadPro = "ipad_pro" 9 | case iPadPro3rdGen = "ipad_pro_3rd_gen" 10 | case iPhone65Hero = "iphone_65_hero" 11 | case iPhone55Hero = "iphone_55_hero" 12 | case iPadProHero = "ipad_pro_hero" 13 | case iPadPro3rdGenHero = "ipad_pro_3rd_gen_hero" 14 | 15 | public init?(argument: String) { 16 | self.init(rawValue: argument) 17 | } 18 | 19 | public var value: SampleLayout { 20 | switch self { 21 | case .iPadPro: return .iPadPro 22 | case .iPadPro3rdGen: return .iPadPro3rdGen 23 | case .iPhone55: return .iPhone55 24 | case .iPhone65: return .iPhone65 25 | case .iPhone65Hero: return .iPhone65Hero 26 | case .iPadPro3rdGenHero: return .iPadPro3rdGenHero 27 | case .iPadProHero: return .iPadProHero 28 | case .iPhone55Hero: return .iPhone55Hero 29 | } 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /Sources/SampleFrameKitLayout/SampleLayout.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import FrameKit 3 | 4 | public struct SampleLayout: LayoutProvider { 5 | public let size: CGSize 6 | public let deviceFrameOffset: CGSize 7 | public let textInsets: EdgeInsets 8 | public let imageInsets: EdgeInsets 9 | public let keywordFontSize: CGFloat 10 | public let titleFontSize: CGFloat 11 | public let textGap: CGFloat 12 | public let textColor: Color 13 | public let backgroundColor: Color 14 | 15 | public init( 16 | size: CGSize, 17 | deviceFrameOffset: CGSize, 18 | textInsets: EdgeInsets, 19 | imageInsets: EdgeInsets, 20 | keywordFontSize: CGFloat, 21 | titleFontSize: CGFloat, 22 | textGap: CGFloat, 23 | textColor: Color, 24 | backgroundColor: Color 25 | ) { 26 | self.size = size 27 | self.deviceFrameOffset = deviceFrameOffset 28 | self.textInsets = textInsets 29 | self.imageInsets = imageInsets 30 | self.keywordFontSize = keywordFontSize 31 | self.titleFontSize = titleFontSize 32 | self.textGap = textGap 33 | self.textColor = textColor 34 | self.backgroundColor = backgroundColor 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/FrameKit/DeviceFrameView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// The SwiftUI View struct that is used in `DeviceImage`. 4 | /// 5 | /// Currently this view layout relies on the fact that iPhone's vessel size match other side of it. 6 | /// So that we can simply put a screenshot image onto the center of a device frame image! Thanks, Steve. 7 | /// If we need to deal with asymmetry devices or images (e.g. iPhone 5c having lock button on its top), 8 | /// we need to adjust the position of screenshot with offset to account of buttons. 9 | public struct DeviceFrameView: View { 10 | public let deviceFrame: NSImage 11 | public let screenshot: NSImage 12 | public let offset: CGSize 13 | 14 | public init(deviceFrame: NSImage, screenshot: NSImage, offset: CGSize = .zero) { 15 | self.deviceFrame = deviceFrame 16 | self.screenshot = screenshot 17 | self.offset = offset 18 | } 19 | 20 | public var body: some View { 21 | // Two images are combined by overlapping each other on ZStack 22 | ZStack { 23 | Image(nsImage: screenshot) 24 | .resizable() 25 | .frame(width: screenshot.size.width, height: screenshot.size.height) 26 | .offset(self.offset) 27 | Image(nsImage: deviceFrame) 28 | .resizable() 29 | .frame(width: deviceFrame.size.width, height: deviceFrame.size.height) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/pages_docc.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Single deploy job since we're just deploying 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: macos-12 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v3 33 | - run: mkdir ./docs 34 | - name: Generate DocC docs 35 | run: | 36 | swift --version 37 | swift package \ 38 | --allow-writing-to-directory ./docs \ 39 | generate-documentation \ 40 | --target FrameKit \ 41 | --transform-for-static-hosting \ 42 | --hosting-base-path FrameKit \ 43 | --output-path ./docs 44 | - name: Setup Pages 45 | uses: actions/configure-pages@v1 46 | - name: Upload artifact 47 | uses: actions/upload-pages-artifact@v1 48 | with: 49 | # Upload only docs directory 50 | path: './docs' 51 | - name: Deploy to GitHub Pages 52 | id: deployment 53 | uses: actions/deploy-pages@v1 54 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.6 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: "FrameKit", 8 | platforms: [ 9 | .macOS(.v10_15) 10 | ], 11 | products: [ 12 | .executable(name: "framekit", targets: ["SampleFrameKitCLI"]), 13 | .library(name: "FrameKit", targets: ["FrameKit"]), 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/apple/swift-argument-parser", from: "0.5.0"), 17 | .package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"), 18 | .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.0.0"), 19 | ], 20 | targets: [ 21 | .target( 22 | name: "FrameKit", 23 | dependencies: [ 24 | .product(name: "Algorithms", package: "swift-algorithms") 25 | ] 26 | ), 27 | .executableTarget( 28 | name: "SampleFrameKitCLI", 29 | dependencies: [ 30 | .target(name: "FrameKit"), 31 | .target(name: "SampleFrameKitLayout"), 32 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 33 | ] 34 | ), 35 | .target( 36 | name: "SampleFrameKitLayout", 37 | dependencies: [ 38 | .target(name: "FrameKit"), 39 | ] 40 | ), 41 | .testTarget( 42 | name: "FrameKitTests", 43 | dependencies: [ 44 | .target(name: "FrameKit") 45 | ] 46 | ), 47 | ] 48 | ) 49 | -------------------------------------------------------------------------------- /Sources/FrameKit/StoreScreenshotRenderer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import AppKit 3 | import SwiftUI 4 | 5 | public struct StoreScreenshotRenderer { 6 | public enum Error: Swift.Error { 7 | case imageOperationFailure(String) 8 | case fileSavingFailure(String) 9 | } 10 | 11 | private let outputPath: String 12 | private let imageFormat: NSBitmapImageRep.FileType 13 | private let layoutDirection: LayoutDirection 14 | 15 | public init(outputPath: String, imageFormat: NSBitmapImageRep.FileType, layoutDirection: LayoutDirection) { 16 | self.outputPath = outputPath 17 | self.imageFormat = imageFormat 18 | self.layoutDirection = layoutDirection 19 | } 20 | 21 | public func callAsFunction(_ storeScreenshotView: View) throws { 22 | let view = NSHostingView(rootView: storeScreenshotView.environment(\.layoutDirection, layoutDirection)) 23 | view.frame = CGRect( 24 | x: 0, 25 | y: 0, 26 | width: storeScreenshotView.layout.size.width, 27 | height: storeScreenshotView.layout.size.height 28 | ) 29 | view.layer?.contentsScale = 1.0 30 | 31 | // Output image to specified outputPath 32 | guard let data = convertToImage(view: view, format: imageFormat) else { 33 | throw Error.imageOperationFailure("Error: can't generate image from view") 34 | } 35 | 36 | let result = FileManager.default.createFile(atPath: outputPath, contents: data, attributes: nil) 37 | guard result else { 38 | throw Error.fileSavingFailure("Error: can't save generated image at \(String(describing: outputPath))") 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/SampleFrameKitLayout/SampleStoreScreenshotView.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import FrameKit 3 | import SwiftUI 4 | 5 | public struct SampleStoreScreenshotView: StoreScreenshotView { 6 | public let layout: SampleLayout 7 | public let content: SampleContent 8 | 9 | public static func makeView(layout: SampleLayout, content: SampleContent) -> Self { 10 | Self(layout: layout, content: content) 11 | } 12 | 13 | public init( 14 | layout: SampleLayout, 15 | content: SampleContent 16 | ) { 17 | self.layout = layout 18 | self.content = content 19 | } 20 | 21 | public var body: some View { 22 | ZStack { 23 | // Background Color 24 | layout.backgroundColor 25 | 26 | // Background Image 27 | content.backgroundImage.map { backgroundImage in 28 | Image(nsImage: backgroundImage) 29 | .resizable() 30 | .aspectRatio(contentMode: .fit) 31 | } 32 | 33 | // Image 34 | HStack(alignment: .bottom) { 35 | Image(nsImage: content.framedScreenshots[0]) 36 | .resizable() 37 | .aspectRatio(contentMode: .fit) 38 | } 39 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) 40 | .padding(layout.imageInsets) 41 | 42 | // Text 43 | HStack(alignment: .top) { 44 | VStack(alignment: .leading, spacing: layout.textGap) { 45 | Text(content.keyword) 46 | .font(keywordFont) 47 | .foregroundColor(layout.textColor) 48 | .multilineTextAlignment(.leading) 49 | .lineLimit(nil) 50 | 51 | Text(content.title) 52 | .font(titleFont) 53 | .foregroundColor(layout.textColor) 54 | .multilineTextAlignment(.leading) 55 | .lineLimit(nil) 56 | } 57 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 58 | .padding(self.layout.textInsets) 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/FrameKit/DeviceFrame.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import SwiftUI 3 | 4 | /// `DeviceFrame` provides a way to embed app screenshot into device frame image. 5 | public struct DeviceFrame { 6 | public enum Error: Swift.Error { 7 | case fileNotFound(String) 8 | } 9 | 10 | /// To make an NSImage object that has image combining store screenshot and device frame image togther 11 | /// - Parameters: 12 | /// - screenshot: A relative or absolute path to app screenshot image file 13 | /// - deviceFrame: A relative or absolute path to device frame image file 14 | /// - deviceFrameOffset: Offset to adjust the position of app screenshot 15 | /// - Returns: an image object. `nil` if something went wrong. 16 | public static func makeImage(screenshot: String, deviceFrame: String, deviceFrameOffset: CGSize) throws -> NSImage? { 17 | guard let screenshotImage = NSImage(contentsOfFile: absolutePath(screenshot)) else { 18 | throw Error.fileNotFound("screenshot was not found at \(screenshot)") 19 | } 20 | 21 | guard let deviceFrameImage = NSImage(contentsOfFile: absolutePath(deviceFrame)) else { 22 | throw Error.fileNotFound("device frame was not found at \(deviceFrame)") 23 | } 24 | 25 | // Device frame's image needs to be generted separaratedly to make framing logic easy 26 | return makeDeviceFrameImage( 27 | screenshot: screenshotImage, 28 | deviceFrame: deviceFrameImage, 29 | deviceFrameOffset: deviceFrameOffset 30 | ) 31 | } 32 | 33 | public static func makeDeviceFrameImage(screenshot: NSImage, deviceFrame: NSImage, deviceFrameOffset: CGSize) -> NSImage? { 34 | let pngData = makeDeviceFrameData(screenshot: screenshot, deviceFrame: deviceFrame, deviceFrameOffset: deviceFrameOffset) 35 | return pngData.flatMap { NSImage(data: $0) } 36 | } 37 | 38 | public static func makeDeviceFrameData(screenshot: NSImage, deviceFrame: NSImage, deviceFrameOffset: CGSize) -> Data? { 39 | let deviceFrameView = DeviceFrameView( 40 | deviceFrame: deviceFrame, 41 | screenshot: screenshot, 42 | offset: deviceFrameOffset 43 | ) 44 | let view = NSHostingView(rootView: deviceFrameView) 45 | view.layer?.contentsScale = 1.0 46 | view.frame = CGRect(x: 0, y: 0, width: deviceFrame.size.width, height: deviceFrame.size.height) 47 | 48 | // Use png here to use alpha layer 49 | return convertToImage(view: view, format: .png) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | .DS_Store 5 | 6 | ## User settings 7 | xcuserdata/ 8 | 9 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 10 | *.xcscmblueprint 11 | *.xccheckout 12 | 13 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 14 | build/ 15 | DerivedData/ 16 | *.moved-aside 17 | *.pbxuser 18 | !default.pbxuser 19 | *.mode1v3 20 | !default.mode1v3 21 | *.mode2v3 22 | !default.mode2v3 23 | *.perspectivev3 24 | !default.perspectivev3 25 | 26 | ## Obj-C/Swift specific 27 | *.hmap 28 | 29 | ## App packaging 30 | *.ipa 31 | *.dSYM.zip 32 | *.dSYM 33 | 34 | ## Playgrounds 35 | timeline.xctimeline 36 | playground.xcworkspace 37 | 38 | # Swift Package Manager 39 | # 40 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 41 | # Packages/ 42 | # Package.pins 43 | # Package.resolved 44 | # *.xcodeproj 45 | # 46 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 47 | # hence it is not needed unless you have added a package configuration file to your project 48 | .swiftpm 49 | 50 | .build/ 51 | 52 | # CocoaPods 53 | # 54 | # We recommend against adding the Pods directory to your .gitignore. However 55 | # you should judge for yourself, the pros and cons are mentioned at: 56 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 57 | # 58 | # Pods/ 59 | # 60 | # Add this line if you want to avoid checking in source code from the Xcode workspace 61 | # *.xcworkspace 62 | 63 | # Carthage 64 | # 65 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 66 | # Carthage/Checkouts 67 | 68 | Carthage/Build/ 69 | 70 | # Accio dependency management 71 | Dependencies/ 72 | .accio/ 73 | 74 | # fastlane 75 | # 76 | # It is recommended to not store the screenshots in the git repo. 77 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 78 | # For more information about the recommended setup visit: 79 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 80 | 81 | fastlane/report.xml 82 | fastlane/Preview.html 83 | fastlane/screenshots/**/*.png 84 | fastlane/test_output 85 | 86 | # Code Injection 87 | # 88 | # After new code Injection tools there's a generated folder /iOSInjectionProject 89 | # https://github.com/johnno1962/injectionforxcode 90 | 91 | iOSInjectionProject/ 92 | 93 | .vscode -------------------------------------------------------------------------------- /Sources/SampleFrameKitLayout/SampleHeroStoreScreenshotView.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import FrameKit 3 | import SwiftUI 4 | 5 | public struct SampleHeroStoreScreenshotView: StoreScreenshotView { 6 | public let layout: SampleLayout 7 | public let content: SampleContent 8 | 9 | public static func makeView(layout: SampleLayout, content: SampleContent) -> Self { 10 | Self(layout: layout, content: content) 11 | } 12 | 13 | public init( 14 | layout: SampleLayout, 15 | content: SampleContent 16 | ) { 17 | self.layout = layout 18 | self.content = content 19 | } 20 | 21 | public var body: some View { 22 | ZStack { 23 | // Background Colour 24 | layout.backgroundColor 25 | 26 | // Background Image 27 | content.backgroundImage.map { backgroundImage in 28 | Image(nsImage: backgroundImage) 29 | .resizable() 30 | .aspectRatio(contentMode: .fit) 31 | } 32 | 33 | // Images 34 | GeometryReader() { geometry in 35 | ZStack { 36 | HStack(alignment: .center, spacing: 10) { 37 | Image(nsImage: content.framedScreenshots[1]) 38 | .resizable() 39 | .aspectRatio(contentMode: .fit) 40 | .frame(width: geometry.size.width / 2.6) 41 | 42 | Spacer() 43 | 44 | Image(nsImage: content.framedScreenshots[2]) 45 | .resizable() 46 | .aspectRatio(contentMode: .fit) 47 | .frame(width: geometry.size.width / 2.6) 48 | } 49 | 50 | Image(nsImage: content.framedScreenshots[0]) 51 | .resizable() 52 | .aspectRatio(contentMode: .fit) 53 | .frame(width: geometry.size.width / 2.2, alignment: .center) 54 | } 55 | .frame(maxWidth: .infinity, maxHeight: .infinity) 56 | .padding(self.layout.imageInsets) 57 | } 58 | 59 | // Text 60 | HStack { 61 | Text(content.keyword) 62 | .font(keywordFont) 63 | .foregroundColor(self.layout.textColor) 64 | .multilineTextAlignment(.leading) 65 | .lineLimit(nil) 66 | } 67 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) 68 | .padding(self.layout.textInsets) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/SampleFrameKitCLI/Command.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import AppKit 3 | import FrameKit 4 | import SampleFrameKitLayout 5 | import enum SwiftUI.LayoutDirection 6 | 7 | @main 8 | public struct Command: ParsableCommand { 9 | public static var configuration: CommandConfiguration { 10 | CommandConfiguration(commandName: "frameit") 11 | } 12 | 13 | @Option(name: [.customShort("L"), .customLong("layout")], 14 | help: "\(SampleLayoutOption.allCases.map({ "\"\($0.rawValue)\"" }).joined(separator: ", "))", 15 | completion: .list(SampleLayoutOption.allCases.map(\.rawValue))) 16 | var layout: SampleLayoutOption 17 | 18 | @Option(name: .shortAndLong, help: "A target locale's identifier to be used to adjust layout within view") 19 | var locale: String 20 | 21 | @Option(name: .shortAndLong, help: "A string to be shown with bold font") 22 | var keyword: String 23 | 24 | @Option(name: .shortAndLong, help: "A string to be shown with regular font") 25 | var title: String 26 | 27 | @Option(name: .shortAndLong, 28 | help: "An absolute or relative path to the image to be shown as background", 29 | completion: .file()) 30 | var backgroundImage: String? 31 | 32 | 33 | @Option(name: .shortAndLong, 34 | help: """ 35 | An absolute or relative path to the image to be shown as the device frame. Download them by 'fastlane frameit download_frames') 36 | """, 37 | completion: .file()) 38 | var deviceFrame: String 39 | 40 | @Option(name: .shortAndLong, help: "An absolute or relative path to output", completion: .file()) 41 | var output: String 42 | 43 | @Flag(name: .long, 44 | help: "To choose hero screenshot view pass this flag.") 45 | var isHero: Bool = false 46 | 47 | @Flag(name: .long, help: "If tehe target is RLT language, then add this") 48 | var isRTL: Bool = false 49 | 50 | @Option(name: .shortAndLong, 51 | help: "An absolute or relative path to the image to be shown as the embeded screenshot within a device frame", 52 | completion: .file()) 53 | var screenshots: [String] = [] 54 | 55 | public init() {} 56 | } 57 | 58 | extension Command { 59 | public mutating func run() throws { 60 | let layoutDirection: LayoutDirection = isRTL ? .rightToLeft : .leftToRight 61 | let layout = layout.value 62 | 63 | // Device frame's image needs to be generted separaratedly to make framing logic easy 64 | let framedScreenshots = try screenshots.compactMap({ screenshot in 65 | try DeviceFrame.makeImage( 66 | screenshot: absolutePath(screenshot), 67 | deviceFrame: absolutePath(deviceFrame), 68 | deviceFrameOffset: layout.deviceFrameOffset 69 | ) 70 | }) 71 | 72 | let content = SampleContent( 73 | locale: Locale(identifier: locale), 74 | keyword: keyword, 75 | title: title, 76 | backgroundImage: backgroundImage.flatMap({ NSImage(contentsOfFile: absolutePath($0)) }), 77 | framedScreenshots: framedScreenshots 78 | ) 79 | 80 | let render = StoreScreenshotRenderer(outputPath: output, imageFormat: .jpeg, layoutDirection: layoutDirection) 81 | if isHero { 82 | try render(SampleHeroStoreScreenshotView.makeView(layout: layout, content: content)) 83 | } else { 84 | try render(SampleStoreScreenshotView.makeView(layout: layout, content: content)) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/SampleFrameKitLayout/SampleLayout+Portrait.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import FrameKit 3 | 4 | extension SampleLayout { 5 | public static let defaultBackgroundColor = Color(red: 255 / 255, green: 153 / 255, blue: 51 / 255) 6 | 7 | public static let iPhone65 = Self( 8 | size: CGSize(width: 1242, height: 2688), 9 | deviceFrameOffset: .zero, 10 | textInsets: EdgeInsets(top: 72, leading: 120, bottom: 0, trailing: 120), 11 | imageInsets: EdgeInsets(top: 0, leading: 128, bottom: 72, trailing: 128), 12 | keywordFontSize: 148, 13 | titleFontSize: 72, 14 | textGap: 24, 15 | textColor: .white, 16 | backgroundColor: defaultBackgroundColor 17 | ) 18 | 19 | public static let iPhone55 = Self( 20 | size: CGSize(width: 1242, height: 2208), 21 | deviceFrameOffset: .zero, 22 | textInsets: EdgeInsets(top: 36, leading: 96, bottom: 0, trailing: 96), 23 | imageInsets: EdgeInsets(top: 0, leading: 84, bottom: -500, trailing: 84), 24 | keywordFontSize: 148, 25 | titleFontSize: 72, 26 | textGap: 24, 27 | textColor: .white, 28 | backgroundColor: defaultBackgroundColor 29 | ) 30 | 31 | public static let iPadPro = Self( 32 | size: CGSize(width: 2048, height: 2732), 33 | deviceFrameOffset: .zero, 34 | textInsets: EdgeInsets(top: 48, leading: 96, bottom: 0, trailing: 96), 35 | imageInsets: EdgeInsets(top: 0, leading: 150, bottom: -200, trailing: 150), 36 | keywordFontSize: 148, 37 | titleFontSize: 72, 38 | textGap: 24, 39 | textColor: .white, 40 | backgroundColor: defaultBackgroundColor 41 | ) 42 | 43 | public static let iPadPro3rdGen = Self( 44 | size: CGSize(width: 2048, height: 2732), 45 | deviceFrameOffset: CGSize(width: -1, height: 1), 46 | textInsets: EdgeInsets(top: 48, leading: 96, bottom: 0, trailing: 96), 47 | imageInsets: EdgeInsets(top: 0, leading: 96, bottom: -200, trailing: 96), 48 | keywordFontSize: 148, 49 | titleFontSize: 72, 50 | textGap: 24, 51 | textColor: .white, 52 | backgroundColor: defaultBackgroundColor 53 | ) 54 | } 55 | 56 | extension SampleLayout { 57 | public static let iPhone65Hero = Self( 58 | size: CGSize(width: 1242, height: 2688), 59 | deviceFrameOffset: .zero, 60 | textInsets: EdgeInsets(top: 0, leading: 96, bottom: 240, trailing: 96), 61 | imageInsets: EdgeInsets(top: 0, leading: 84, bottom: 96, trailing: 84), 62 | keywordFontSize: 108, 63 | titleFontSize: 0, 64 | textGap: 24, 65 | textColor: .white, 66 | backgroundColor: defaultBackgroundColor 67 | ) 68 | 69 | public static let iPhone55Hero = Self( 70 | size: CGSize(width: 1242, height: 2208), 71 | deviceFrameOffset: .zero, 72 | textInsets: EdgeInsets(top: 0, leading: 96, bottom: 240, trailing: 96), 73 | imageInsets: EdgeInsets(top: 0, leading: 84, bottom: 96, trailing: 84), 74 | keywordFontSize: 108, 75 | titleFontSize: 0, 76 | textGap: 24, 77 | textColor: .white, 78 | backgroundColor: defaultBackgroundColor 79 | ) 80 | 81 | public static let iPadProHero = Self( 82 | size: CGSize(width: 2048, height: 2732), 83 | deviceFrameOffset: .zero, 84 | textInsets: EdgeInsets(top: 0, leading: 96, bottom: 240, trailing: 96), 85 | imageInsets: EdgeInsets(top: 0, leading: 150, bottom: 148, trailing: 150), 86 | keywordFontSize: 108, 87 | titleFontSize: 0, 88 | textGap: 24, 89 | textColor: .white, 90 | backgroundColor: defaultBackgroundColor 91 | ) 92 | 93 | public static let iPadPro3rdGenHero = Self( 94 | size: CGSize(width: 2048, height: 2732), 95 | deviceFrameOffset: CGSize(width: -1, height: 1), 96 | textInsets: EdgeInsets(top: 0, leading: 96, bottom: 240, trailing: 96), 97 | imageInsets: EdgeInsets(top: 0, leading: 96, bottom: 148, trailing: 96), 98 | keywordFontSize: 108, 99 | titleFontSize: 0, 100 | textGap: 24, 101 | textColor: .white, 102 | backgroundColor: defaultBackgroundColor 103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /Sources/FrameKit/TextWrap.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Algorithms 3 | import NaturalLanguage 4 | 5 | public struct TextWrap { 6 | /// Return a String including breaklines that break up a given script up to given number. This is useful to 7 | /// determine the best distributed text that laid out on screenshots. The logic is to first list up the chunk of words 8 | /// and then work out standard deviation on each way of breaking and then choose the most distributed pattern. 9 | /// 10 | /// - Parameters: 11 | /// - input: Input string to break lines 12 | /// - separator: Character to separate words in scripts. Depending on languages, this can be blank string, like Japanese, Chinese, etc 13 | /// - numberOfLines: number of lines to break up the input 14 | /// - Returns: A String including breaklines that break up a given script up to given number 15 | public static func wrapTextEqually(_ input: String, into numberOfLines: Int, using separator: String = " ") -> String { 16 | let wordsAndTags:[(Substring, NLTag?)] = splitByWord(input) 17 | let separatePatterns = wordsAndTags.indices.combinations(ofCount: numberOfLines - 1) 18 | var scores: [[String]: Double] = [:] 19 | var best: [String] = [] 20 | var bestScore: Double = .infinity 21 | 22 | outerLoop: for separateIndexes in separatePatterns { 23 | var startIndex = wordsAndTags.startIndex 24 | var linesAndTags: [Array<(Substring, NLTag?)>.SubSequence] = [] 25 | for separateIndex in separateIndexes { 26 | let subsequence = wordsAndTags[startIndex...separateIndex] 27 | linesAndTags.append(subsequence) 28 | startIndex = separateIndex.advanced(by: 1) 29 | } 30 | linesAndTags.append(wordsAndTags[startIndex.. [(Substring, NLTag?)] { 81 | var wordsAndTags = [(Substring, NLTag?)]() 82 | let tagger = NLTagger(tagSchemes: [.lexicalClass]) 83 | tagger.string = input 84 | tagger.enumerateTags( 85 | in: input.startIndex.. Double { 97 | let charCounts = lines.map(\.count).map(Double.init) 98 | return stdev(of: charCounts) 99 | } 100 | 101 | private static func stdev(of values: [Double]) -> Double { 102 | // https://github.com/apple/swift-argument-parser/blob/898d1ae9dd7d9ff9af39e66bea744d52f69df2e3/Examples/math/Math.swift#L174-L180 103 | let sum: Double = values.reduce(0, +) 104 | let mean = sum / Double(values.count) 105 | let squaredErrors = values 106 | .map { $0 - mean } 107 | .map { $0 * $0 } 108 | let variance = squaredErrors.reduce(0, +) / Double(values.count) 109 | let result = variance.squareRoot() 110 | return result 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FrameKit 2 | 3 | A Swift macOS app libary that helps you build stunning App Store screenshots with SwiftUI. This library contains a collection of class/protocol that are required in making workflows to deal with mobile app's store screenshots. 4 | 5 | * [Why FrameKit?](https://github.com/ainame/FrameKit#why-framekit) 6 | * [Get started](https://github.com/ainame/FrameKit#get-started) 7 | * [References](https://github.com/ainame/FrameKit#references) 8 | 9 | ## Why FrameKit? 10 | 11 | ### What is Store Screenshots? 12 | 13 | In your product page on App Store and Google Play store must have screenshots that showcase the best part of your apps to visually communicate with your app's experience. I call them "store" screenshots whilst I call a plain screenshots directly taken on a device or simulator as "app" screenshots. 14 | 15 | See how store screenshots on product page looks like from here. 16 | * https://developer.apple.com/app-store/product-page/ 17 | * https://play.google.com/console/about/storelistings/ 18 | 19 | ### Why not ask designer to make them? 20 | 21 | Let's say you are working on an app that supports 20 languages in the app and App store/Google Play. Your app has 6 screenshots to showcase great features. If it's support iPad and Android tablet, your designer would need to make screenshots for all of them to cover. 22 | 23 | In total, it could be 20 langs \* (4 screen size for App Store + 2 size for Google Play) \* 6 screenshots = 720 screenshots! It's likely that your designer will give up it in the middle. Not only the designer needs to deal with large number of screenshots but also *someone* needs to take **app** screenshots somewhow. 24 | 25 | So why not automate the process? 26 | 27 | ### Why not fastlane frameit? 28 | 29 | Okay I know the pain but why don't you use [existing solution](https://docs.fastlane.tools/getting-started/ios/screenshots/) in [fastlane/fastlane](https://github.com/fastlane/fastlane)? fastlane snapshot + frameit is the way to go automation, isn't it? 30 | 31 | There's a couple of reasons not to use fastlane for screenshots. Basically fastlane frameit is limtied. 32 | 33 | * Doesn't support Arabic proerply due to the underlying dependency (ImageMagick) https://github.com/fastlane/fastlane/issues/7522 34 | * Only supports 1 app screenshot per 1 store screenshot (Hero screenshot design tends to have multiple app screenshots) 35 | * Even font size can't be fixed and it's dynamic 36 | * Hard to debug 37 | * Designers wouldn't be happy with limtied design achieved by frameit 38 | 39 | ### What Framekit can help? 40 | 41 | * Frame app screenshot into device frame images 42 | * Write store screenshot design with SwiftUI 43 | * Build commandline tools to generate store screenshots with any inputs 44 | * Layout text by maintaining each line length's evenly and not to wrap in the middle of a word even in languages like Japanese and Chineses that have no space between words 45 | * Custom font support 46 | 47 | ## Get started 48 | 49 | ### Pre-requirement 50 | 51 | Although FrameKit doens't depend on fastlane itself, it's much easier for anyone to get device frame images via [fastlane/frameit-frames](https://github.com/fastlane/frameit-frames). 52 | 53 | Try either 54 | 55 | * Install `fastlane` via `gem install fastlane` or Bundler and then run `fastlane frameit download_frames` 56 | * It'll create `~/.fastlane/frameit/latest` directory 57 | * Directly git clone [fastlane/frameit-frames](https://github.com/fastlane/frameit-frames) and use `latest` directory 58 | 59 | ### FrameKit 60 | 61 | FrameKit itself is Swift Package Manager's package so you can install it via SPM. Add below to your `Package.swift`. 62 | 63 | ```swift 64 | .package(url: "https://github.com/ainame/FrameKit", from: "0.1.0"), 65 | ``` 66 | 67 | At least you need to create following things by yourself. 68 | 69 | * SwiftUI's `View` that conforms to `StoreScreenshotView` protocol and implements the store screenshot design 70 | * `Layout` definition conforming `LayoutProvider` protocol, which mean to hold hardcoded any layout values for each screen size 71 | * A `struct` to store screenshot contents; such as title or framed app screenshot images, which will be `StoreScreenshotView.Cotent` 72 | 73 | You can find how it can be from sample code at [`Source/SampleFrameKitLayout`](https://github.com/ainame/FrameKit/tree/main/Sources/SampleFrameKitLayout). 74 | 75 | **Note that FrameKit is a macOS app library so you need to touch AppKit more or less but don't worry most work is done within SwiftUI.** 76 | 77 | Next write your workflow in any format possible. I would recommend to build a CLI tool using [`apple/swift-argument-parser`](https://github.com/apple/swift-argument-parser). See an example at [`Sources/SampleFrameKitCLI`](https://github.com/ainame/FrameKit/tree/main/Sources/SampleFrameKitCLI). 78 | 79 | In the below sample code, FrameKit provides `DeviceFrame` and `StoreScreenshotRenderer` so that you don't have to deal with some magic in AppKit to get JPEG image rendered. 80 | 81 | ```swift 82 | import AppKit 83 | import SwiftUI 84 | import FrameKit 85 | 86 | // Define layout for your desired device (this could be defined somewhere as static property) 87 | // This is what you need to define by yourself 88 | let layoutForiPhone65inch = Layout( 89 | size: CGSize(width: 1242, height: 2688), 90 | deviceFrameOffset: .zero, 91 | textInsets: EdgeInsets(top: 72, leading: 120, bottom: 0, trailing: 120), 92 | imageInsets: EdgeInsets(top: 0, leading: 128, bottom: 72, trailing: 128), 93 | keywordFontSize: 148, 94 | titleFontSize: 72, 95 | textGap: 24, 96 | textColor: .white, 97 | backgroundColor: Color(red: 255 / 255, green: 153 / 255, blue: 51 / 255) 98 | ) 99 | 100 | // DeviceFrame.makeImage is to embed "app" screenshot into given device frame 101 | let framedScreenshot = try DeviceFrame.makeImage( 102 | screenshot: absolutePath(screenshot), 103 | deviceFrame: absolutePath(deviceFrame), 104 | deviceFrameOffset: layoutForiPhone65inch.deviceFrameOffset 105 | ) 106 | 107 | // Arbitary struct to include contents to be rendered on store screenshtos 108 | // This is what you need to define 109 | let content = SampleContent( 110 | keyword: "Weather", 111 | title: "Come to the UK", 112 | framedScreenshots: [framedScreenshot] 113 | ) 114 | 115 | // Ininitialize the designed view that you defined 116 | let view = SampleStoreScreenshotView.makeView(layout: layoutForiPhone65inch, content: content) 117 | 118 | // Render the image into outputPath with this 119 | let render = StoreScreenshotRenderer(outputPath: "./output.jpg", layoutDirection: .leftToRight) 120 | try render(view) 121 | ``` 122 | 123 | ## References 124 | 125 | ### Specifications 126 | 127 | See the official documents 128 | 129 | * App Store https://help.apple.com/app-store-connect/#/devd274dd925 130 | * Google Play https://support.google.com/googleplay/android-developer/answer/9866151?hl=en#zippy=%2Cscreenshots 131 | 132 | ### Acknowledgment 133 | 134 | Although I decided not to go with fastlane frameit, I started this screeshot work with it and it inspired me a lot. 135 | Thank you very much for people contributing frameit. 136 | 137 | And also many works in this repo involved working time at [Cookpad](https://careers.cookpad.com/). 138 | Thank you very much for allowing me to work on this. 139 | --------------------------------------------------------------------------------