Skip to content

Commit

Permalink
Add support for configuring image content gravity (#2177)
Browse files Browse the repository at this point in the history
Co-authored-by: Cal Stephens <cal.stephens@airbnb.com>
  • Loading branch information
matthewcheok and calda authored Sep 12, 2023
1 parent 6277300 commit 8bff4aa
Show file tree
Hide file tree
Showing 33 changed files with 127 additions and 15 deletions.
4 changes: 3 additions & 1 deletion Example/Example/AnimationPreviewView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,9 @@ extension Color {

extension AnimationImageProvider where Self == FilepathImageProvider {
static var exampleAppSampleImages: FilepathImageProvider {
FilepathImageProvider(filepath: Bundle.main.resourceURL!.appending(path: "Samples/Images"))
FilepathImageProvider(
filepath: Bundle.main.resourceURL!.appending(path: "Samples/Images"),
contentsGravity: .resizeAspect)
}
}

Expand Down
1 change: 1 addition & 0 deletions Sources/Private/CoreAnimation/Layers/ImageLayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ final class ImageLayer: BaseCompositionLayer {

self.imageAsset = imageAsset
contentsLayer.contents = image
contentsLayer.contentsGravity = context.imageProvider.contentsGravity(for: imageAsset)
setNeedsLayout()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,10 @@ final class ImageCompositionLayer: CompositionLayer {
}
}
}

var imageContentsGravity: CALayerContentsGravity = .resize {
didSet {
contentsLayer.contentsGravity = imageContentsGravity
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import CoreGraphics
import Foundation
import QuartzCore

// MARK: - CachedImageProvider

Expand Down Expand Up @@ -35,6 +36,11 @@ private final class CachedImageProvider: AnimationImageProvider {

let imageCache: NSCache<NSString, CGImage> = .init()
let imageProvider: AnimationImageProvider

func contentsGravity(for asset: ImageAsset) -> CALayerContentsGravity {
imageProvider.contentsGravity(for: asset)
}

}

extension AnimationImageProvider {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ final class LayerImageProvider {
for imageLayer in imageLayers {
if let asset = imageAssets[imageLayer.imageReferenceID] {
imageLayer.image = imageProvider.imageForAsset(asset: asset)
imageLayer.imageContentsGravity = imageProvider.contentsGravity(for: asset)
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions Sources/Public/ImageProvider/AnimationImageProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import CoreGraphics
import Foundation
import QuartzCore

// MARK: - AnimationImageProvider

Expand All @@ -26,10 +27,19 @@ public protocol AnimationImageProvider {

/// The image to display for the given `ImageAsset` defined in the `LottieAnimation` JSON file.
func imageForAsset(asset: ImageAsset) -> CGImage?

/// Specifies how the layer's contents are positioned or scaled within its bounds for a given asset.
/// Defaults to `.resize`, which stretches the image to fill the layer.
func contentsGravity(for asset: ImageAsset) -> CALayerContentsGravity
}

extension AnimationImageProvider {
public var cacheEligible: Bool {
true
}

/// The default value is `.resize`, similar to that of `CALayer`.
public func contentsGravity(for _: ImageAsset) -> CALayerContentsGravity {
.resize
}
}
9 changes: 8 additions & 1 deletion Sources/Public/iOS/BundleImageProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ public class BundleImageProvider: AnimationImageProvider {
///
/// - Parameter bundle: The bundle containing images for the provider.
/// - Parameter searchPath: The subpath is a path within the bundle to search for image assets.
/// - Parameter contentsGravity: The contents gravity to use when rendering the image.
///
public init(bundle: Bundle, searchPath: String?) {
public init(bundle: Bundle, searchPath: String?, contentsGravity: CALayerContentsGravity = .resize) {
self.bundle = bundle
self.searchPath = searchPath
self.contentsGravity = contentsGravity
}

// MARK: Public
Expand Down Expand Up @@ -81,10 +83,15 @@ public class BundleImageProvider: AnimationImageProvider {
return image.cgImage
}

public func contentsGravity(for _: ImageAsset) -> CALayerContentsGravity {
contentsGravity
}

// MARK: Internal

let bundle: Bundle
let searchPath: String?
let contentsGravity: CALayerContentsGravity
}

extension BundleImageProvider: Equatable {
Expand Down
17 changes: 15 additions & 2 deletions Sources/Public/iOS/FilepathImageProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,21 @@ public class FilepathImageProvider: AnimationImageProvider {
/// Initializes an image provider with a specific filepath.
///
/// - Parameter filepath: The absolute filepath containing the images.
/// - Parameter contentsGravity: The contents gravity to use when rendering the images.
///
public init(filepath: String) {
public init(filepath: String, contentsGravity: CALayerContentsGravity = .resize) {
self.filepath = URL(fileURLWithPath: filepath)
self.contentsGravity = contentsGravity
}

public init(filepath: URL) {
/// Initializes an image provider with a specific filepath.
///
/// - Parameter filepath: The absolute filepath containing the images.
/// - Parameter contentsGravity: The contents gravity to use when rendering the images.
///
public init(filepath: URL, contentsGravity: CALayerContentsGravity = .resize) {
self.filepath = filepath
self.contentsGravity = contentsGravity
}

// MARK: Public
Expand Down Expand Up @@ -52,9 +60,14 @@ public class FilepathImageProvider: AnimationImageProvider {
return nil
}

public func contentsGravity(for _: ImageAsset) -> CALayerContentsGravity {
contentsGravity
}

// MARK: Internal

let filepath: URL
let contentsGravity: CALayerContentsGravity
}

extension FilepathImageProvider: Equatable {
Expand Down
9 changes: 8 additions & 1 deletion Sources/Public/macOS/BundleImageProvider.macOS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ public class BundleImageProvider: AnimationImageProvider {
///
/// - Parameter bundle: The bundle containing images for the provider.
/// - Parameter searchPath: The subpath is a path within the bundle to search for image assets.
/// - Parameter contentsGravity: The contents gravity to use when rendering the image.
///
public init(bundle: Bundle, searchPath: String?) {
public init(bundle: Bundle, searchPath: String?, contentsGravity: CALayerContentsGravity = .resize) {
self.bundle = bundle
self.searchPath = searchPath
self.contentsGravity = contentsGravity
}

// MARK: Public
Expand Down Expand Up @@ -70,10 +72,15 @@ public class BundleImageProvider: AnimationImageProvider {
return image.lottie_CGImage
}

public func contentsGravity(for _: ImageAsset) -> CALayerContentsGravity {
contentsGravity
}

// MARK: Internal

let bundle: Bundle
let searchPath: String?
let contentsGravity: CALayerContentsGravity
}

extension BundleImageProvider: Equatable {
Expand Down
17 changes: 15 additions & 2 deletions Sources/Public/macOS/FilepathImageProvider.macOS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,21 @@ public class FilepathImageProvider: AnimationImageProvider {
/// Initializes an image provider with a specific filepath.
///
/// - Parameter filepath: The absolute filepath containing the images.
/// - Parameter contentsGravity: The contents gravity to use when rendering the images.
///
public init(filepath: String) {
public init(filepath: String, contentsGravity: CALayerContentsGravity = .resize) {
self.filepath = URL(fileURLWithPath: filepath)
self.contentsGravity = contentsGravity
}

public init(filepath: URL) {
/// Initializes an image provider with a specific filepath.
///
/// - Parameter filepath: The absolute filepath containing the images.
/// - Parameter contentsGravity: The contents gravity to use when rendering the images.
///
public init(filepath: URL, contentsGravity: CALayerContentsGravity = .resize) {
self.filepath = filepath
self.contentsGravity = contentsGravity
}

// MARK: Public
Expand Down Expand Up @@ -51,9 +59,14 @@ public class FilepathImageProvider: AnimationImageProvider {
return nil
}

public func contentsGravity(for _: ImageAsset) -> CALayerContentsGravity {
contentsGravity
}

// MARK: Internal

let filepath: URL
let contentsGravity: CALayerContentsGravity
}

extension FilepathImageProvider: Equatable {
Expand Down
2 changes: 1 addition & 1 deletion Tests/CompatibleAnimationViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ final class CompatibleAnimationViewTests: XCTestCase {
#if os(iOS)
let animation = CompatibleAnimation(name: "LottieLogo2", subdirectory: Samples.directoryName, bundle: .lottie)
let animationView = CompatibleAnimationView(compatibleAnimation: animation)
animationView.frame.size = animation.animation!.snapshotSize
animationView.frame.size = animation.animation!.snapshotSize(for: .default)
animationView.currentProgress = 0.5
assertSnapshot(matching: animationView, as: .imageOfPresentationLayer())
#endif
Expand Down
Binary file added Tests/Samples/Images/dog-landscape.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes.
1 change: 1 addition & 0 deletions Tests/Samples/Nonanimating/dog_landscape.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"v":"5.4.3","fr":60,"ip":0,"op":300,"w":442,"h":440,"nm":"Screen Shot 2019-03-20 at 1.17.42 PM","ddd":0,"assets":[{"id":"dog-landscape","w":442,"h":440,"u":"","p":"dog-landscape.jpeg","e":1}],"layers":[{"ddd":0,"ind":1,"ty":2,"nm":"Screen Shot 2019-03-20 at 1.17.42 PM.png","cl":"17 42 png","refId":"dog-landscape","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[221,220,0],"ix":2},"a":{"a":0,"k":[221,220,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":0,"op":300,"st":0,"bm":0}],"markers":[]}
35 changes: 34 additions & 1 deletion Tests/SnapshotConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ struct SnapshotConfiguration {
/// - Enabling this for a set of animations gives us a regression suite for
/// the code supporting the automatic engine.
var testWithAutomaticEngine = false

/// Whether or not this snapshot doesn't animate, so only needs to be snapshot once.
var nonanimating = false

/// The maximum size to allow for the resulting snapshot image
var maxSnapshotDimension: CGFloat = 500
}

// MARK: Custom mapping
Expand Down Expand Up @@ -100,7 +106,13 @@ extension SnapshotConfiguration {
]),

// Test cases for `AnimatedImageProvider`
"Nonanimating/_dog": .customImageProvider(HardcodedImageProvider(imageName: "Samples/Images/dog.png")),
// - These snapshots are pretty large (2 MB) by default, so we limit their number and size.
"Nonanimating/dog": .customImageProvider(HardcodedImageProvider(imageName: "Samples/Images/dog.png"))
.nonanimating()
.precision(0.9),
"Nonanimating/dog_landscape": .customImageProvider(HardcodedImageProvider(imageName: "Samples/Images/dog-landscape.jpeg"))
.nonanimating()
.precision(0.9),

// Test cases for `AnimatedTextProvider`
"Issues/issue_1722": .customTextProvider(HardcodedTextProvider(text: "Bounce-bounce")),
Expand Down Expand Up @@ -190,6 +202,27 @@ extension SnapshotConfiguration {
return configuration
}

/// A copy of this `SnapshotConfiguration` with `nonanimating` set to `true`
func nonanimating(_ value: Bool = true) -> SnapshotConfiguration {
var copy = self
copy.nonanimating = value
return copy
}

/// A copy of this `SnapshotConfiguration` with `maxSnapshotDimension` set to the given value
func maxSnapshotDimension(_ maxSnapshotDimension: CGFloat) -> SnapshotConfiguration {
var copy = self
copy.maxSnapshotDimension = maxSnapshotDimension
return copy
}

/// A copy of this `SnapshotConfiguration` with the given precision when comparing the existing snapshot image
func precision(_ precision: Float) -> SnapshotConfiguration {
var copy = self
copy.precision = precision
return copy
}

/// Whether or not this sample should be included in the snapshot tests for the given configuration
func shouldSnapshot(using configuration: LottieConfiguration) -> Bool {
switch configuration.renderingEngine {
Expand Down
18 changes: 12 additions & 6 deletions Tests/SnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class SnapshotTests: XCTestCase {
.replacingOccurrences(of: "testCoreAnimationRenderingEngine.", with: "")
.replacingOccurrences(of: "testAutomaticRenderingEngine.", with: "")

for percentage in progressPercentagesToSnapshot {
for percentage in progressPercentagesToSnapshot(for: .default) {
animationName = animationName.replacingOccurrences(
of: "-\(Int(percentage * 100)).png",
with: "")
Expand Down Expand Up @@ -99,7 +99,13 @@ class SnapshotTests: XCTestCase {
// MARK: Private

/// `currentProgress` percentages that should be snapshot in `compareSampleSnapshots`
private let progressPercentagesToSnapshot = [0, 0.25, 0.5, 0.75, 1.0]
private func progressPercentagesToSnapshot(for snapshotConfiguration: SnapshotConfiguration) -> [Double] {
if snapshotConfiguration.nonanimating {
return [0]
} else {
return [0, 0.25, 0.5, 0.75, 1.0]
}
}

/// Captures snapshots of `sampleAnimationURLs` and compares them to the snapshot images stored on disk
private func compareSampleSnapshots(
Expand All @@ -111,7 +117,7 @@ class SnapshotTests: XCTestCase {

#if os(iOS)
for sampleAnimationName in Samples.sampleAnimationNames {
for percent in progressPercentagesToSnapshot {
for percent in progressPercentagesToSnapshot(for: SnapshotConfiguration.forSample(named: sampleAnimationName)) {
guard
let animationView = await SnapshotConfiguration.makeAnimationView(
for: sampleAnimationName,
Expand All @@ -138,8 +144,8 @@ class SnapshotTests: XCTestCase {

extension LottieAnimation {
/// The size that this animation should be snapshot at
var snapshotSize: CGSize {
let maxDimension: CGFloat = 500
func snapshotSize(for configuration: SnapshotConfiguration) -> CGSize {
let maxDimension: CGFloat = configuration.maxSnapshotDimension

// If this is a landscape aspect ratio, we clamp the width
if width > height {
Expand Down Expand Up @@ -293,7 +299,7 @@ extension SnapshotConfiguration {

// Set up the animation view with a valid frame
// so the geometry is correct when setting up the `CAAnimation`s
animationView.frame.size = animation.snapshotSize
animationView.frame.size = animation.snapshotSize(for: snapshotConfiguration)

for (keypath, customValueProvider) in snapshotConfiguration.customValueProviders {
animationView.setValueProvider(customValueProvider, keypath: keypath)
Expand Down
4 changes: 4 additions & 0 deletions Tests/Utils/HardcodedImageProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ struct HardcodedImageProvider: AnimationImageProvider {
return nil
#endif
}

func contentsGravity(for _: ImageAsset) -> CALayerContentsGravity {
.resizeAspectFill
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Supports Core Animation engine
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Supports Core Animation engine
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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 8bff4aa

Please sign in to comment.