From 7fd034f39bef02ca0fd8070a0eec8e340dda7017 Mon Sep 17 00:00:00 2001 From: Damien Rambout Date: Mon, 14 Sep 2015 21:52:16 -0700 Subject: [PATCH 1/2] [PR #8] Added CompositeImageFilter protocol to construct composite filters. --- Source/ImageFilter.swift | 121 +++++++++++------------------------ Tests/ImageFilterTests.swift | 10 +-- 2 files changed, 44 insertions(+), 87 deletions(-) diff --git a/Source/ImageFilter.swift b/Source/ImageFilter.swift index c1788df1..b3071d1f 100644 --- a/Source/ImageFilter.swift +++ b/Source/ImageFilter.swift @@ -78,17 +78,6 @@ extension ImageFilter where Self: Roundable { } } -extension ImageFilter where Self: Sizable, Self: Roundable { - /// The unique idenitifier for an `ImageFilter` conforming to both the `Sizable` and `Roundable` protocols. - public var identifier: String { - let width = Int64(round(size.width)) - let height = Int64(round(size.height)) - let radius = Int64(round(self.radius)) - - return "\(self.dynamicType)-size:(\(width)x\(height))-radius:(\(radius))" - } -} - #if os(iOS) || os(watchOS) // MARK: - Single Pass Image Filters (iOS and watchOS only) - @@ -246,16 +235,32 @@ public struct BlurFilter: ImageFilter { #endif -// MARK: - Multi-Pass Image Filters (iOS and watchOS only) - +// MARK: - Composite Image Filters (iOS and watchOS only) - -/// Scales an image to a specified size, then rounds the corners to the specified radius. -public struct ScaledToSizeWithRoundedCornersFilter: ImageFilter, Sizable, Roundable { - /// The size of the filter. - public let size: CGSize +/// The `CompositeImageFilter` protocol defines an additional `filters` property to support multiple composite filters. +public protocol CompositeImageFilter: ImageFilter { + /// The image filters to apply to the image in sequential order. + var filters: [ImageFilter] { get } +} - /// The radius of the filter. - public let radius: CGFloat +public extension CompositeImageFilter { + /// The unique idenitifier for any `CompositeImageFilter` type. + var identifier: String { + return filters.map { $0.identifier }.joinWithSeparator("_") + } + /// The filter closure for any `CompositeImageFilter` type. + var filter: Image -> Image { + return { image in + return self.filters.reduce(image) { $1.filter($0) } + } + } +} + +// MARK: - + +/// Scales an image to a specified size, then rounds the corners to the specified radius. +public struct ScaledToSizeWithRoundedCornersFilter: CompositeImageFilter { /** Initializes the `ScaledToSizeWithRoundedCornersFilter` instance with the given size and radius. @@ -265,32 +270,18 @@ public struct ScaledToSizeWithRoundedCornersFilter: ImageFilter, Sizable, Rounda - returns: The new `ScaledToSizeWithRoundedCornersFilter` instance. */ public init(size: CGSize, radius: CGFloat) { - self.size = size - self.radius = radius + self.filters = [ScaledToSizeFilter(size: size), RoundedCornersFilter(radius: radius)] } - /// The filter closure used to create the modified representation of the given image. - public var filter: Image -> Image { - return { image in - let scaledImage = image.af_imageScaledToSize(self.size) - let roundedAndScaledImage = scaledImage.af_imageWithRoundedCornerRadius(self.radius * image.scale) - - return roundedAndScaledImage - } - } + /// The image filters to apply to the image in sequential order. + public let filters: [ImageFilter] } // MARK: - /// Scales an image from the center while maintaining the aspect ratio to fit within a specified size, then rounds the /// corners to the specified radius. -public struct AspectScaledToFillSizeWithRoundedCornersFilter: ImageFilter, Sizable, Roundable { - /// The size of the filter. - public let size: CGSize - - /// The radius of the filter. - public let radius: CGFloat - +public struct AspectScaledToFillSizeWithRoundedCornersFilter: CompositeImageFilter { /** Initializes the `AspectScaledToFillSizeWithRoundedCornersFilter` instance with the given size and radius. @@ -300,31 +291,17 @@ public struct AspectScaledToFillSizeWithRoundedCornersFilter: ImageFilter, Sizab - returns: The new `AspectScaledToFillSizeWithRoundedCornersFilter` instance. */ public init(size: CGSize, radius: CGFloat) { - self.size = size - self.radius = radius + self.filters = [AspectScaledToFillSizeFilter(size: size), RoundedCornersFilter(radius: radius)] } - /// The filter closure used to create the modified representation of the given image. - public var filter: Image -> Image { - return { image in - let scaledImage = image.af_imageAspectScaledToFillSize(self.size) - let roundedAndScaledImage = scaledImage.af_imageWithRoundedCornerRadius(self.radius * image.scale) - - return roundedAndScaledImage - } - } + /// The image filters to apply to the image in sequential order. + public let filters: [ImageFilter] } // MARK: - /// Scales an image to a specified size, then rounds the corners into a circle. -public struct ScaledToSizeCircleFilter: ImageFilter, Sizable, Roundable { - /// The size of the filter. - public let size: CGSize - - /// The radius of the filter. - public let radius: CGFloat - +public struct ScaledToSizeCircleFilter: CompositeImageFilter { /** Initializes the `ScaledToSizeCircleFilter` instance with the given size. @@ -333,32 +310,18 @@ public struct ScaledToSizeCircleFilter: ImageFilter, Sizable, Roundable { - returns: The new `ScaledToSizeCircleFilter` instance. */ public init(size: CGSize) { - self.size = size - self.radius = min(size.width, size.height) / 2.0 + self.filters = [ScaledToSizeFilter(size: size), CircleFilter()] } - /// The filter closure used to create the modified representation of the given image. - public var filter: Image -> Image { - return { image in - let scaledImage = image.af_imageScaledToSize(self.size) - let scaledCircleImage = scaledImage.af_imageRoundedIntoCircle() - - return scaledCircleImage - } - } + /// The image filters to apply to the image in sequential order. + public let filters: [ImageFilter] } // MARK: - /// Scales an image from the center while maintaining the aspect ratio to fit within a specified size, then rounds the /// corners into a circle. -public struct AspectScaledToFillSizeCircleFilter: ImageFilter, Sizable, Roundable { - /// The size of the filter. - public let size: CGSize - - /// The radius of the filter. - public let radius: CGFloat - +public struct AspectScaledToFillSizeCircleFilter: CompositeImageFilter { /** Initializes the `AspectScaledToFillSizeCircleFilter` instance with the given size. @@ -367,19 +330,11 @@ public struct AspectScaledToFillSizeCircleFilter: ImageFilter, Sizable, Roundabl - returns: The new `AspectScaledToFillSizeCircleFilter` instance. */ public init(size: CGSize) { - self.size = size - self.radius = min(size.width, size.height) / 2.0 + self.filters = [AspectScaledToFillSizeFilter(size: size), CircleFilter()] } - /// The filter closure used to create the modified representation of the given image. - public var filter: Image -> Image { - return { image in - let scaledImage = image.af_imageAspectScaledToFillSize(self.size) - let scaledCircleImage = scaledImage.af_imageRoundedIntoCircle() - - return scaledCircleImage - } - } + /// The image filters to apply to the image in sequential order. + public let filters: [ImageFilter] } #endif diff --git a/Tests/ImageFilterTests.swift b/Tests/ImageFilterTests.swift index 3bf55ee2..3e2d7d9c 100644 --- a/Tests/ImageFilterTests.swift +++ b/Tests/ImageFilterTests.swift @@ -30,7 +30,7 @@ class ImageFilterTestCase: BaseTestCase { let largeSquareSize = CGSize(width: 100, height: 100) let scale = Int(round(UIScreen.mainScreen().scale)) - // MARK: - Protocol Extension Identifiers + // MARK: - ImageFilter Protocol Extension Identifiers func testThatImageFilterIdentifierIsImplemented() { // Given @@ -65,7 +65,9 @@ class ImageFilterTestCase: BaseTestCase { XCTAssertEqual(identifier, "RoundedCornersFilter-radius:(12)", "identifier does not match expected value") } - func testThatImageFilterWhereSelfIsSizableAndRoundableIdentifierIsImplemented() { + // MARK: - CompositeImageFilter Protocol Extension Identifiers + + func testThatCompositeImageFilterIdentifierIsImplemented() { // Given let filter = ScaledToSizeWithRoundedCornersFilter(size: CGSize(width: 200, height: 100), radius: 20.0123) @@ -75,7 +77,7 @@ class ImageFilterTestCase: BaseTestCase { // Then XCTAssertEqual( identifier, - "ScaledToSizeWithRoundedCornersFilter-size:(200x100)-radius:(20)", + "ScaledToSizeFilter-size:(200x100)_RoundedCornersFilter-radius:(20)", "identifier does not match expected value" ) } @@ -163,7 +165,7 @@ class ImageFilterTestCase: BaseTestCase { XCTAssertTrue(pixelsMatch, "pixels match should be true") } - // MARK: - Multi-Pass Image Filter Tests + // MARK: - Composite Image Filter Tests func testThatScaledToSizeWithRoundedCornersFilterReturnsCorrectFilteredImage() { // Given From 8ab53f6d947f9444babe441f61ce20226804d9cf Mon Sep 17 00:00:00 2001 From: Christian Noon Date: Tue, 15 Sep 2015 20:59:44 -0700 Subject: [PATCH 2/2] Rounded corner radius can now be adjusted by the image scale. Rounding the corners of an image requires two different approaches. If the image has the same resolution for all screen scales, the radius MUST be divided by the image scale in order to produce the same visual round across all device scales. If the image has multiple resolutions tailored to each screen scale, the same radius can be applied on devices of all screen scales. --- Source/ImageFilter.swift | 56 +++++++++++++++++++++++------ Source/UIImage+AlamofireImage.swift | 11 ++++-- Tests/ImageFilterTests.swift | 15 ++++---- Tests/UIImageTests.swift | 8 ++--- 4 files changed, 65 insertions(+), 25 deletions(-) diff --git a/Source/ImageFilter.swift b/Source/ImageFilter.swift index b3071d1f..ebbf618e 100644 --- a/Source/ImageFilter.swift +++ b/Source/ImageFilter.swift @@ -166,23 +166,41 @@ public struct RoundedCornersFilter: ImageFilter, Roundable { /// The radius of the filter. public let radius: CGFloat + /// Whether to divide the radius by the image scale. + public let divideRadiusByImageScale: Bool + /** Initializes the `RoundedCornersFilter` instance with the given radius. - - parameter radius: The radius. + - parameter radius: The radius. + - parameter divideRadiusByImageScale: Whether to divide the radius by the image scale. Set to `true` when the + image has the same resolution for all screen scales such as @1x, @2x and + @3x (i.e. single image from web server). Set to `false` for images loaded + from an asset catalog with varying resolutions for each screen scale. + `false` by default. - returns: The new `RoundedCornersFilter` instance. */ - public init(radius: CGFloat) { + public init(radius: CGFloat, divideRadiusByImageScale: Bool = false) { self.radius = radius + self.divideRadiusByImageScale = divideRadiusByImageScale } /// The filter closure used to create the modified representation of the given image. public var filter: Image -> Image { return { image in - return image.af_imageWithRoundedCornerRadius(self.radius) + return image.af_imageWithRoundedCornerRadius( + self.radius, + divideRadiusByImageScale: self.divideRadiusByImageScale + ) } } + + /// The unique idenitifier for an `ImageFilter` conforming to the `Roundable` protocol. + public var identifier: String { + let radius = Int64(round(self.radius)) + return "\(self.dynamicType)-radius:(\(radius))-divided:(\(divideRadiusByImageScale))" + } } // MARK: - @@ -264,13 +282,21 @@ public struct ScaledToSizeWithRoundedCornersFilter: CompositeImageFilter { /** Initializes the `ScaledToSizeWithRoundedCornersFilter` instance with the given size and radius. - - parameter size: The size. - - parameter radius: The radius. + - parameter size: The size. + - parameter radius: The radius. + - parameter divideRadiusByImageScale: Whether to divide the radius by the image scale. Set to `true` when the + image has the same resolution for all screen scales such as @1x, @2x and + @3x (i.e. single image from web server). Set to `false` for images loaded + from an asset catalog with varying resolutions for each screen scale. + `false` by default. - returns: The new `ScaledToSizeWithRoundedCornersFilter` instance. */ - public init(size: CGSize, radius: CGFloat) { - self.filters = [ScaledToSizeFilter(size: size), RoundedCornersFilter(radius: radius)] + public init(size: CGSize, radius: CGFloat, divideRadiusByImageScale: Bool = false) { + self.filters = [ + ScaledToSizeFilter(size: size), + RoundedCornersFilter(radius: radius, divideRadiusByImageScale: divideRadiusByImageScale) + ] } /// The image filters to apply to the image in sequential order. @@ -285,13 +311,21 @@ public struct AspectScaledToFillSizeWithRoundedCornersFilter: CompositeImageFilt /** Initializes the `AspectScaledToFillSizeWithRoundedCornersFilter` instance with the given size and radius. - - parameter size: The size. - - parameter radius: The radius. + - parameter size: The size. + - parameter radius: The radius. + - parameter divideRadiusByImageScale: Whether to divide the radius by the image scale. Set to `true` when the + image has the same resolution for all screen scales such as @1x, @2x and + @3x (i.e. single image from web server). Set to `false` for images loaded + from an asset catalog with varying resolutions for each screen scale. + `false` by default. - returns: The new `AspectScaledToFillSizeWithRoundedCornersFilter` instance. */ - public init(size: CGSize, radius: CGFloat) { - self.filters = [AspectScaledToFillSizeFilter(size: size), RoundedCornersFilter(radius: radius)] + public init(size: CGSize, radius: CGFloat, divideRadiusByImageScale: Bool = false) { + self.filters = [ + AspectScaledToFillSizeFilter(size: size), + RoundedCornersFilter(radius: radius, divideRadiusByImageScale: divideRadiusByImageScale) + ] } /// The image filters to apply to the image in sequential order. diff --git a/Source/UIImage+AlamofireImage.swift b/Source/UIImage+AlamofireImage.swift index 3d75d8d4..fbb9d957 100644 --- a/Source/UIImage+AlamofireImage.swift +++ b/Source/UIImage+AlamofireImage.swift @@ -201,14 +201,19 @@ extension UIImage { /** Returns a new version of the image with the corners rounded to the specified radius. - - parameter radius: The radius to use when rounding the new image. + - parameter radius: The radius to use when rounding the new image. + - parameter divideRadiusByImageScale: Whether to divide the radius by the image scale. Set to `true` when the + image has the same resolution for all screen scales such as @1x, @2x and + @3x (i.e. single image from web server). Set to `false` for images loaded + from an asset catalog with varying resolutions for each screen scale. + `false` by default. - returns: A new image object. */ - public func af_imageWithRoundedCornerRadius(radius: CGFloat) -> UIImage { + public func af_imageWithRoundedCornerRadius(radius: CGFloat, divideRadiusByImageScale: Bool = false) -> UIImage { UIGraphicsBeginImageContextWithOptions(size, false, 0.0) - let scaledRadius = radius / scale + let scaledRadius = divideRadiusByImageScale ? radius / scale : radius let clippingPath = UIBezierPath(roundedRect: CGRect(origin: CGPointZero, size: size), cornerRadius: scaledRadius) clippingPath.addClip() diff --git a/Tests/ImageFilterTests.swift b/Tests/ImageFilterTests.swift index 3e2d7d9c..fb0d9e53 100644 --- a/Tests/ImageFilterTests.swift +++ b/Tests/ImageFilterTests.swift @@ -62,7 +62,8 @@ class ImageFilterTestCase: BaseTestCase { let identifier = filter.identifier // Then - XCTAssertEqual(identifier, "RoundedCornersFilter-radius:(12)", "identifier does not match expected value") + let expectedIdentifier = "RoundedCornersFilter-radius:(12)-divided:(false)" + XCTAssertEqual(identifier, expectedIdentifier, "identifier does not match expected value") } // MARK: - CompositeImageFilter Protocol Extension Identifiers @@ -75,11 +76,8 @@ class ImageFilterTestCase: BaseTestCase { let identifier = filter.identifier // Then - XCTAssertEqual( - identifier, - "ScaledToSizeFilter-size:(200x100)_RoundedCornersFilter-radius:(20)", - "identifier does not match expected value" - ) + let expectedIdentifier = "ScaledToSizeFilter-size:(200x100)_RoundedCornersFilter-radius:(20)-divided:(false)" + XCTAssertEqual(identifier, expectedIdentifier, "identifier does not match expected value") } // MARK: - Single Pass Image Filter Tests @@ -126,7 +124,7 @@ class ImageFilterTestCase: BaseTestCase { func testThatRoundedCornersFilterReturnsCorrectFilteredImage() { // Given let image = imageForResource("pirate", withExtension: "jpg") - let filter = RoundedCornersFilter(radius: 20) + let filter = RoundedCornersFilter(radius: 20, divideRadiusByImageScale: true) // When let filteredImage = filter.filter(image) @@ -134,6 +132,9 @@ class ImageFilterTestCase: BaseTestCase { // Then let expectedFilteredImage = imageForResource("pirate-radius-20", withExtension: "png") XCTAssertTrue(filteredImage.af_isEqualToImage(expectedFilteredImage), "filtered image pixels do not match") + + let expectedIdentifier = "RoundedCornersFilter-radius:(20)-divided:(true)" + XCTAssertEqual(filter.identifier, expectedIdentifier, "filter identifier does not match") } func testThatCircleFilterReturnsCorrectFilteredImage() { diff --git a/Tests/UIImageTests.swift b/Tests/UIImageTests.swift index 91e6d190..0fe89ab3 100644 --- a/Tests/UIImageTests.swift +++ b/Tests/UIImageTests.swift @@ -261,10 +261,10 @@ class UIImageTestCase: BaseTestCase { let r = Int(round(radius)) // When - let roundedAppleImage = appleImage.af_imageWithRoundedCornerRadius(radius) - let roundedPirateImage = pirateImage.af_imageWithRoundedCornerRadius(radius) - let roundedRainbowImage = rainbowImage.af_imageWithRoundedCornerRadius(radius) - let roundedUnicornImage = unicornImage.af_imageWithRoundedCornerRadius(radius) + let roundedAppleImage = appleImage.af_imageWithRoundedCornerRadius(radius, divideRadiusByImageScale: true) + let roundedPirateImage = pirateImage.af_imageWithRoundedCornerRadius(radius, divideRadiusByImageScale: true) + let roundedRainbowImage = rainbowImage.af_imageWithRoundedCornerRadius(radius, divideRadiusByImageScale: true) + let roundedUnicornImage = unicornImage.af_imageWithRoundedCornerRadius(radius, divideRadiusByImageScale: true) // Then let expectedAppleImage = imageForResource("apple-radius-\(r)", withExtension: "png")