Skip to content

Commit

Permalink
Initial Commit
Browse files Browse the repository at this point in the history
  • Loading branch information
Sherlouk committed Jun 26, 2021
0 parents commit dd07a03
Show file tree
Hide file tree
Showing 12 changed files with 349 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.swiftpm
.DS_Store
16 changes: 16 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"object": {
"pins": [
{
"package": "SnapshotTesting",
"repositoryURL": "https://github.com/pointfreeco/swift-snapshot-testing.git",
"state": {
"branch": null,
"revision": "f8a9c997c3c1dab4e216a8ec9014e23144cbab37",
"version": "1.9.0"
}
}
]
},
"version": 1
}
35 changes: 35 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "swift-snapshot-testing-stitch",
platforms: [
.iOS(.v11),
],
products: [
.library(
name: "swift-snapshot-testing-stitch",
targets: ["SnapshotTestingStitch"]
),
],
dependencies: [
.package(name: "SnapshotTesting",
url: "https://github.com/pointfreeco/swift-snapshot-testing.git", from: "1.8.0"),
],
targets: [
.target(
name: "SnapshotTestingStitch",
dependencies: [
.product(name: "SnapshotTesting", package: "SnapshotTesting"),
]
),

.testTarget(
name: "SnapshotTestingStitchTests",
dependencies: ["SnapshotTestingStitch"],
exclude: ["__Snapshots__"]
),
]
)
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# SnapshotTesting Stitch

This is an extension to [SnapshotTesting](https://github.com/pointfreeco/swift-snapshot-testing) which allows you to create images combining the output of multiple snapshot strategies assuming they all output to UIImage.

In essence, this allows you to have a single image which represents a single snapshotted value in multiple different configurations. This might be useful in situations, for example, where you have the same UIViewController and want a single image showing the view in multiple sizes.

Images may also have titles, allowing you to easily identify each configuration within the image.

## Usage

See tests for example usage.
57 changes: 57 additions & 0 deletions Sources/SnapshotTestingStitch/Snapshotting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import UIKit
import SnapshotTesting

extension Snapshotting where Value == UIViewController, Format == UIImage {

public static func stitch(
strategies: [Snapshotting<Value, Format>],
style: StitchStyle = .init()
) -> Snapshotting {
// Default to an empty string, if they choose not to provide one.
stitch(strategies: strategies.map { ("", $0) }, style: style)
}

public static func stitch(
strategies: [(name: String, strategy: Snapshotting<Value, Format>)],
style: StitchStyle = .init()
) -> Snapshotting {
let internalStrategy: Snapshotting<UIViewController, UIImage> = .image

return Snapshotting(
pathExtension: internalStrategy.pathExtension,
diffing: internalStrategy.diffing
) { value in
Async<UIImage> { callback in
// Create a dispatch group to keep track of the remaining tasks
let dispatchGroup = DispatchGroup()

// Create an array to store the final outputs to be stitched
var values = [(String, UIImage)]()

// Loop over each of the user-provided strategies, snapshot them,
// store the output, and update the dispatch group.
strategies.forEach { strategy in
dispatchGroup.enter()

strategy.strategy.snapshot(value).run { output in
values.append((strategy.name, output))
dispatchGroup.leave()
}
}

// Once all strategies have been completed...
dispatchGroup.notify(queue: .main) {
// Sort values based on input order
let comparableValues = strategies.compactMap { (name, _) in
values.first(where: { $0.0 == name })
}

// Stitch them together, and callback to the snapshot testing library.
let image = ImageStitcher(inputs: comparableValues).stitch(style: style)
callback(image)
}
}
}
}

}
41 changes: 41 additions & 0 deletions Sources/SnapshotTestingStitch/StitchStyle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import UIKit

public struct StitchStyle {
/// How large should the font size be for the optional titles which appear above each stitched image?
public let fontSize: CGFloat

/// What color should the border be which surrounds each individual stitched image?
public let borderColor: UIColor

/// How thick should the border be which surrounds each individual stitched image?
public let borderWidth: CGFloat

/// How far apart should each stitched image be from another?
public let itemSpacing: CGFloat

/// Creates a defintion of how a stitched snapshot should be presented.
///
/// Allows you to customise certain aspects of the stitched output image.
///
/// - Parameters:
/// - fontSize: How large should the font size be for the optional titles which appear above each stitched image?
/// - borderColor: What color should the border be which surrounds each individual stitched image?
/// - borderWidth: How thick should the border be which surrounds each individual stitched image?
/// - itemSpacing: How far apart should each stitched image be from another?
public init(
fontSize: CGFloat = 20,
borderColor: UIColor = .red,
borderWidth: CGFloat = 5,
itemSpacing: CGFloat = 32
) {
assert(borderWidth >= 0, "The provided border width should be a positive integer, or zero if you do not want a border to be displayed.")
assert(itemSpacing >= 0, "The provided item spacing should be a positive integer.")
assert(fontSize >= 0, "The provided font size should be a positive integer.")

self.fontSize = fontSize
self.borderColor = borderColor
self.borderWidth = borderWidth
self.itemSpacing = itemSpacing
}

}
111 changes: 111 additions & 0 deletions Sources/SnapshotTestingStitch/Stitcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import UIKit

struct ImageStitcher {

let inputs: [(title: String, image: UIImage)]

func stitch(style: StitchStyle) -> UIImage {

let includeTitles: Bool = inputs.map { $0.title }.allSatisfy { $0 == "" } == false
let images = inputs.map { $0.image }

// Calculate how large the full image will be based on the inputs
let framePadding: CGFloat = style.itemSpacing

let largestHeight = images.map { $0.size.height }.max() ?? 0
let computedHeight =
largestHeight +
(includeTitles ? style.fontSize + framePadding : 0) + // Title Size
(style.borderWidth * 2) + // Vertical Border
(framePadding * 2) // Vertical Padding

let imageSumWidth = images.map {
$0.size.width +
(style.borderWidth * 2) + // Horizontal Border
style.itemSpacing // Horizontal Interitem Spacing
}.reduce(0, +)

let computedWidth =
imageSumWidth +
(framePadding * 2) - // Horizontal Padding
style.itemSpacing // Remove Final Image's Interitem Spacing

// Create renderer with correct image size
let computedSize = CGSize(
width: computedWidth,
height: computedHeight
)

let renderer = UIGraphicsImageRenderer(size: computedSize)

// Setup title styling
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .center

let titleAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: style.fontSize),
.paragraphStyle: paragraphStyle,
.foregroundColor: UIColor.white
]

// Draw the image
let image = renderer.image { context in
context.cgContext.setFillColor(UIColor.black.cgColor)
context.cgContext.fill(CGRect(origin: .zero, size: computedSize))

var xOffset = framePadding + style.borderWidth
var yOffset = framePadding + style.borderWidth

// If we have a title to display, then we'll increase the yOffset to leave space at the top
if includeTitles {
yOffset += style.fontSize + framePadding
}

inputs.forEach { (title, image) in
// Draw Border
context.cgContext.setFillColor(style.borderColor.cgColor)
context.cgContext.setStrokeColor(style.borderColor.cgColor)
context.cgContext.setLineWidth(style.borderWidth)

context.cgContext.addRect(CGRect(
x: xOffset,
y: yOffset,
width: image.size.width + (style.borderWidth * 2),
height: image.size.height + (style.borderWidth * 2)
))

context.cgContext.drawPath(using: .fillStroke)

// Draw Image
image.draw(at: CGPoint(
x: xOffset + style.borderWidth,
y: yOffset + style.borderWidth
))

// Draw Title
if title.isEmpty == false {
title.draw(
with: CGRect(
x: xOffset,
y: framePadding,
width: image.size.width,
height: image.size.height
),
options: .usesLineFragmentOrigin,
attributes: titleAttributes,
context: nil
)
}

// Increment horizontal offset for next image
xOffset +=
image.size.width +
style.itemSpacing +
(style.borderWidth * 2)
}
}

return image
}

}
76 changes: 76 additions & 0 deletions Tests/SnapshotTestingStitchTests/SnapshotTestingStitch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import XCTest
import SnapshotTesting
@testable import SnapshotTestingStitch

final class SnapshotTestingStitchTests: XCTestCase {

let isRecording: Bool = false

func test_withTitles() {
assertSnapshot(
matching: createTestViewController(),
as: .stitch(
strategies: [
("iPhone 8", .image(on: .iPhone8)),
("iPhone 8 Plus", .image(on: .iPhone8Plus)),
]
),
record: isRecording
)
}

func test_withoutTitles() {
assertSnapshot(
matching: createTestViewController(),
as: .stitch(
strategies: [
.image(on: .iPhone8),
.image(on: .iPhone8Plus),
]
),
record: isRecording
)
}

func test_withoutBorder() {
assertSnapshot(
matching: createTestViewController(),
as: .stitch(
strategies: [
("iPhone 8", .image(on: .iPhone8)),
("iPhone 8 Plus", .image(on: .iPhone8Plus)),
],
style: .init(borderWidth: 0)
),
record: isRecording
)
}

func test_withManyDevices() {
assertSnapshot(
matching: createTestViewController(),
as: .stitch(
strategies: [
("iPhone 8", .image(on: .iPhone8)),
("iPhone 8 Plus", .image(on: .iPhone8Plus)),
("iPhone X", .image(on: .iPhoneX)),
("iPhone SE", .image(on: .iPhoneSe)),
("iPhone Xr", .image(on: .iPhoneXr)),
("iPhone Xs Max", .image(on: .iPhoneXsMax)),
("iPhone Xs Max (Landscape)", .image(on: .iPhoneXsMax(.landscape))),
]
),
record: isRecording
)
}

// MARK: - Helpers

func createTestViewController() -> UIViewController {
let viewController = UIViewController()
viewController.view.backgroundColor = .blue

return viewController
}

}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit dd07a03

Please sign in to comment.