Skip to content

Commit

Permalink
Add better image text attachment API
Browse files Browse the repository at this point in the history
  • Loading branch information
rnystrom committed Oct 21, 2018
1 parent 36d511d commit 6d050b2
Show file tree
Hide file tree
Showing 9 changed files with 142 additions and 49 deletions.
61 changes: 57 additions & 4 deletions Source/StyledText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,29 @@ import UIKit

public class StyledText: Hashable, Equatable {

public struct ImageFitOptions: OptionSet {
public let rawValue: Int

public init(rawValue: Int) {
self.rawValue = rawValue
}

public static let fit = ImageFitOptions(rawValue: 1 << 0)
public static let center = ImageFitOptions(rawValue: 2 << 0)
}

public enum Storage: Hashable, Equatable {
case text(String)
case attributedText(NSAttributedString)
case image(UIImage, [ImageFitOptions])

// MARK: Hashable

public var hashValue: Int {
switch self {
case .text(let text): return text.hashValue
case .attributedText(let text): return text.hashValue
case .image(let image, _): return image.hashValue
}
}

Expand All @@ -31,13 +44,19 @@ public class StyledText: Hashable, Equatable {
case .text(let lhsText):
switch rhs {
case .text(let rhsText): return lhsText == rhsText
case .attributedText: return false
case .attributedText, .image: return false
}
case .attributedText(let lhsText):
switch rhs {
case .text: return false
case .text, .image: return false
case .attributedText(let rhsText): return lhsText == rhsText
}
case .image(let lhsImage, let lhsOptions):
switch rhs {
case .text, .attributedText: return false
case .image(let rhsImage, let rhsOptions):
return lhsImage == rhsImage && lhsOptions == rhsOptions
}
}
}

Expand All @@ -59,14 +78,17 @@ public class StyledText: Hashable, Equatable {
switch storage {
case .text(let text): return text
case .attributedText(let text): return text.string
case .image: return ""
}
}

internal func render(contentSizeCategory: UIContentSizeCategory) -> NSAttributedString {
var attributes = style.attributes
attributes[.font] = style.font(contentSizeCategory: contentSizeCategory)
let font = style.font(contentSizeCategory: contentSizeCategory)
attributes[.font] = font
switch storage {
case .text(let text): return NSAttributedString(string: text, attributes: attributes)
case .text(let text):
return NSAttributedString(string: text, attributes: attributes)
case .attributedText(let text):
guard text.length > 0 else { return text }
let mutable = text.mutableCopy() as? NSMutableAttributedString ?? NSMutableAttributedString()
Expand All @@ -78,6 +100,37 @@ public class StyledText: Hashable, Equatable {
}
}
return mutable
case .image(let image, let options):
let attachment = NSTextAttachment()
attachment.image = image

var bounds = attachment.bounds
let size = image.size
if options.contains(.fit) {
let ratio = size.width / size.height
let fontHeight = min(ceil(font.pointSize), size.height)
bounds.size.width = ratio * fontHeight
bounds.size.height = fontHeight
} else {
bounds.size = size
}

if options.contains(.center) {
bounds.origin.y = round((font.capHeight - bounds.height) / 2)
}
attachment.bounds = bounds

// non-breaking space so the color hack doesn't wrap
let attributedString = NSMutableAttributedString(string: "\u{00A0}")
attributedString.append(NSAttributedString(attachment: attachment))
// replace attributes to 0 size font so no actual space taken
attributes[.font] = UIFont.systemFont(ofSize: 0)
// override all attributes so color actually tints image
attributedString.addAttributes(
attributes,
range: NSRange(location: 0, length: attributedString.length)
)
return attributedString
}
}

Expand Down
28 changes: 6 additions & 22 deletions Source/StyledTextBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,28 +132,12 @@ public final class StyledTextBuilder: Hashable, Equatable {
}

@discardableResult
public func add(image: UIImage) -> StyledTextBuilder {
guard let tipStyle = savedStyles.last else { return self }

let font = tipStyle.font(contentSizeCategory: .medium)
let imageSize = CGSize(width: font.pointSize, height: font.pointSize)
let mid = font.descender + font.capHeight
let bounds = CGRect(x: 0,
y: round(font.descender - imageSize.height / 2 + mid + 1),
width: imageSize.width,
height: imageSize.height)

let textAttachment = NSTextAttachment()
textAttachment.image = image
textAttachment.bounds = bounds

let textAttributes: [NSAttributedStringKey: Any] = [
.font: font,
.kern: 1.0
]

let attributedString = NSAttributedString(attachment: textAttachment)
return add(storage: .attributedText(attributedString), traits: nil, attributes: textAttributes)
public func add(
image: UIImage,
options: [StyledText.ImageFitOptions] = [.fit, .center],
attributes: [NSAttributedStringKey: Any]? = nil
) -> StyledTextBuilder {
return add(storage: .image(image, options), attributes: attributes)
}

@discardableResult
Expand Down
12 changes: 8 additions & 4 deletions StyledTextKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@

/* Begin PBXBuildFile section */
20DEC7A5869AE2DBDEBEDC3A /* Pods_Tests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73D1CEE14004B5BBA25A669D /* Pods_Tests.framework */; };
2906BB4E2156E305006A4837 /* icons8-github-30.png in Resources */ = {isa = PBXBuildFile; fileRef = 3D4040F620F40F6300ED4B3D /* icons8-github-30.png */; };
294F654D20C38F4E0040C3FE /* StyledTextKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 29822E0E1FE05247008532D2 /* StyledTextKit.framework */; };
29B20605217C0FFA00E4DD9F /* octoface@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 29B20603217C0FFA00E4DD9F /* octoface@3x.png */; };
29B20606217C0FFA00E4DD9F /* octoface@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 29B20604217C0FFA00E4DD9F /* octoface@2x.png */; };
29BAEE3820C339D100F283EA /* CGImage+LRUCachable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29BAEE2320C339D100F283EA /* CGImage+LRUCachable.swift */; };
29BAEE3920C339D100F283EA /* CGSize+LRUCachable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29BAEE2420C339D100F283EA /* CGSize+LRUCachable.swift */; };
29BAEE3A20C339D100F283EA /* NSAttributedStringKey+StyledText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29BAEE2520C339D100F283EA /* NSAttributedStringKey+StyledText.swift */; };
Expand Down Expand Up @@ -53,6 +54,8 @@
290D453620C389CF008F2CB0 /* Snap_swift.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Snap_swift.framework; sourceTree = BUILT_PRODUCTS_DIR; };
294F654820C38F4E0040C3FE /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
29822E0E1FE05247008532D2 /* StyledTextKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = StyledTextKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
29B20603217C0FFA00E4DD9F /* octoface@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "octoface@3x.png"; path = "../../GitHawk/Resources/Assets.xcassets/octoface.imageset/octoface@3x.png"; sourceTree = "<group>"; };
29B20604217C0FFA00E4DD9F /* octoface@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "octoface@2x.png"; path = "../../GitHawk/Resources/Assets.xcassets/octoface.imageset/octoface@2x.png"; sourceTree = "<group>"; };
29BAEDFD20C339C800F283EA /* LRUCacheTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LRUCacheTests.swift; sourceTree = "<group>"; };
29BAEDFE20C339C800F283EA /* UIFontUnionTraitsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIFontUnionTraitsTests.swift; sourceTree = "<group>"; };
29BAEE0220C339C800F283EA /* reference_test_lorem_100@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "reference_test_lorem_100@3x.png"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -90,7 +93,6 @@
29BAEE3620C339D100F283EA /* StyledText.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StyledText.swift; sourceTree = "<group>"; };
29BAEE3720C339D100F283EA /* StyledTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StyledTextView.swift; sourceTree = "<group>"; };
29BAEE4D20C33A3F00F283EA /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
3D4040F620F40F6300ED4B3D /* icons8-github-30.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "icons8-github-30.png"; sourceTree = "<group>"; };
5DA9D30284894EF619A71075 /* Pods-Tests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Tests/Pods-Tests.debug.xcconfig"; sourceTree = "<group>"; };
660B43ECDF42D84142029C1F /* Pods-Tests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tests.release.xcconfig"; path = "Pods/Target Support Files/Pods-Tests/Pods-Tests.release.xcconfig"; sourceTree = "<group>"; };
73D1CEE14004B5BBA25A669D /* Pods_Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Tests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -149,7 +151,8 @@
29BAEDFC20C339C800F283EA /* Tests */ = {
isa = PBXGroup;
children = (
3D4040F620F40F6300ED4B3D /* icons8-github-30.png */,
29B20604217C0FFA00E4DD9F /* octoface@2x.png */,
29B20603217C0FFA00E4DD9F /* octoface@3x.png */,
29BAEE1120C339C800F283EA /* Info.plist */,
29BAEDFD20C339C800F283EA /* LRUCacheTests.swift */,
29BAEDFF20C339C800F283EA /* snapshots */,
Expand Down Expand Up @@ -358,7 +361,8 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
2906BB4E2156E305006A4837 /* icons8-github-30.png in Resources */,
29B20606217C0FFA00E4DD9F /* octoface@2x.png in Resources */,
29B20605217C0FFA00E4DD9F /* octoface@3x.png in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
19 changes: 0 additions & 19 deletions Tests/StyledTextBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,23 +65,4 @@ class StyledTextBuilderTests: XCTestCase {
XCTAssertFalse(font3.fontDescriptor.symbolicTraits.contains(.traitItalic))
}

func test_whenAddingUIImage() {
let bundle = Bundle(for: type(of: self))
guard let imagePath = bundle.path(forResource: "icons8-github-30", ofType: "png"),
let image = UIImage(contentsOfFile: imagePath) else
{
XCTFail()
return
}

let string = StyledTextBuilder(styledText: StyledText(storage: .text("foo"), style: TextStyle(font: .system(.bold))))
.save()
.add(image: image)
.build()

let render = string.render(contentSizeCategory: .medium)

XCTAssertTrue(render.containsAttachments(in: NSRange(location: 3, length: 1)))
}

}
71 changes: 71 additions & 0 deletions Tests/StyledTextRendererSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ class SnapTests: XCTestCase {
compaction: .default,
clearOnWarning: true
)
var githubImage: UIImage {
return UIImage(
named: "octoface",
in: Bundle(for: type(of: self)),
compatibleWith: nil
)!.withRenderingMode(.alwaysTemplate)
}

override func setUp() {
super.setUp()
Expand Down Expand Up @@ -155,4 +162,68 @@ class SnapTests: XCTestCase {
)
expect(UIView().mount(width: 300, renderer: renderer)).toMatchSnapshot()
}

func test_addingImageWithTint_withBaseOptions() {
let string = StyledTextBuilder(text: "Hello ")
.save()
.add(image: githubImage, attributes: [.foregroundColor: UIColor.green])
.restore()
.add(text: " world!")
.build()
let renderer = StyledTextRenderer(
string: string,
contentSizeCategory: .large,
inset: .zero,
backgroundColor: .white,
layoutManager: NSLayoutManager(),
scale: testScale,
maximumNumberOfLines: 2,
sizeCache: sizeCache,
bitmapCache: bitmapCache
)
expect(UIView().mount(width: 300, renderer: renderer)).toMatchSnapshot()
}

func test_addingImageWithTint_withCenter() {
let string = StyledTextBuilder(text: "Hello ")
.save()
.add(image: githubImage, options: [.center], attributes: [.foregroundColor: UIColor.green])
.restore()
.add(text: " world!")
.build()
let renderer = StyledTextRenderer(
string: string,
contentSizeCategory: .large,
inset: .zero,
backgroundColor: .white,
layoutManager: NSLayoutManager(),
scale: testScale,
maximumNumberOfLines: 2,
sizeCache: sizeCache,
bitmapCache: bitmapCache
)
expect(UIView().mount(width: 300, renderer: renderer)).toMatchSnapshot()
}

func test_addingImageWithTint_withNoOptions() {
let string = StyledTextBuilder(text: "Hello ")
.save()
.add(image: githubImage, options: [], attributes: [.foregroundColor: UIColor.green])
.restore()
.add(text: " world!")
.build()
let renderer = StyledTextRenderer(
string: string,
contentSizeCategory: .large,
inset: .zero,
backgroundColor: .white,
layoutManager: NSLayoutManager(),
scale: testScale,
maximumNumberOfLines: 2,
sizeCache: sizeCache,
bitmapCache: bitmapCache
)
expect(UIView().mount(width: 300, renderer: renderer)).toMatchSnapshot()
}

}
Binary file removed Tests/icons8-github-30.png
Binary file not shown.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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 6d050b2

Please sign in to comment.