diff --git a/Package.swift b/Package.swift index 1dfbe91..7ff564b 100644 --- a/Package.swift +++ b/Package.swift @@ -1,28 +1,15 @@ -// swift-tools-version: 5.6 -// The swift-tools-version declares the minimum version of Swift required to build this package. +// swift-tools-version:5.1 import PackageDescription let package = Package( name: "AttributedFont", + platforms: [.iOS(.v13), .macOS(.v10_15), .tvOS(.v13), .watchOS(.v6)], products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "AttributedFont", - targets: ["AttributedFont"]), - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), + .library(name: "AttributedFont", targets: ["AttributedFont", "Previews"]) ], targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "AttributedFont", - dependencies: []), - .testTarget( - name: "AttributedFontTests", - dependencies: ["AttributedFont"]), + .target(name: "AttributedFont", dependencies: []), + .target(name: "Previews", dependencies: ["AttributedFont"]) ] ) diff --git a/Sources/AttributedFont/AttributedFont+Font.swift b/Sources/AttributedFont/AttributedFont+Font.swift new file mode 100644 index 0000000..8bd061e --- /dev/null +++ b/Sources/AttributedFont/AttributedFont+Font.swift @@ -0,0 +1,74 @@ +import SwiftUI + +// MARK: Creating Custom Fonts + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +extension AttributedFont { + + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + public static func custom(_ name: String, fixedSize: CGFloat, attributes: Attributes) -> Self { + return .init(name: name, fixedSize: fixedSize, attributes: attributes) + } +} + +// MARK: Styling a Font + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +extension AttributedFont { + + public func italic() -> Self { + var modified = self + modified.font = modified.font.italic() + return modified + } + + public func smallCaps() -> Self { + var modified = self + modified.font = modified.font.smallCaps() + return modified + } + + public func lowercaseSmallCaps() -> Self { + var modified = self + modified.font = modified.font.lowercaseSmallCaps() + return modified + } + + public func uppercaseSmallCaps() -> Self { + var modified = self + modified.font = modified.font.uppercaseSmallCaps() + return modified + } + + public func monospacedDigit() -> Self { + var modified = self + modified.font = modified.font.monospacedDigit() + return modified + } + + public func weight(_ weight: Font.Weight) -> Self { + var modified = self + modified.font = modified.font.weight(weight) + return modified + } + + public func bold() -> Self { + var modified = self + modified.font = modified.font.bold() + return modified + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + public func monospaced() -> Self { + var modified = self + modified.font = modified.font.monospaced() + return modified + } + + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + public func leading(_ leading: Font.Leading) -> Self { + var modified = self + modified.font = modified.font.leading(leading) + return modified + } +} diff --git a/Sources/AttributedFont/AttributedFont.swift b/Sources/AttributedFont/AttributedFont.swift index 277469f..5af2216 100644 --- a/Sources/AttributedFont/AttributedFont.swift +++ b/Sources/AttributedFont/AttributedFont.swift @@ -1,6 +1,57 @@ -public struct AttributedFont { - public private(set) var text = "Hello, World!" +import SwiftUI - public init() { +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public struct AttributedFont: Hashable { + + public internal(set) var name: String + public internal(set) var size: CGFloat + public internal(set) var attributes: Attributes + + public internal(set) var font: Font + public internal(set) var ctFont: CTFont + + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + internal init(name: String, fixedSize: CGFloat, attributes: Attributes) { + self.name = name + self.size = fixedSize + self.attributes = attributes + + self.font = .custom(name, fixedSize: fixedSize) + self.ctFont = CTFontCreateWithName(name as CFString, size, nil) + } +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +extension AttributedFont { + + public struct Attributes: Hashable { + + public internal(set) var kerning: CGFloat? + public internal(set) var tracking: CGFloat? + public internal(set) var lineHeightMultiple: CGFloat? + + public init(kerning: CGFloat? = nil, tracking: CGFloat? = nil, lineHeightMultiple: CGFloat? = nil) { + self.kerning = kerning + self.tracking = tracking + self.lineHeightMultiple = lineHeightMultiple + } + } +} + + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +extension AttributedFont { + + public var lineSpacing: CGFloat? { + guard let lineHeightMultiple = attributes.lineHeightMultiple else { + return nil + } + let originalLineHeight = CTFontGetLineHeight(ctFont) + let customLineHeight = originalLineHeight * lineHeightMultiple + guard customLineHeight > originalLineHeight else { + return nil + } + let lineSpacing = customLineHeight - originalLineHeight + return lineSpacing } } diff --git a/Sources/AttributedFont/AttributedText+Text.swift b/Sources/AttributedFont/AttributedText+Text.swift new file mode 100644 index 0000000..ca6cb26 --- /dev/null +++ b/Sources/AttributedFont/AttributedText+Text.swift @@ -0,0 +1,259 @@ +import SwiftUI + +// MARK: Creating a Text View from a String + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +extension AttributedText { + + public init(_ key: LocalizedStringKey, tableName: String? = nil, bundle: Bundle? = nil, comment: StaticString? = nil) { + self.init(text: Text(key, tableName: tableName, bundle: bundle, comment: comment)) + } + + @inlinable public init(verbatim content: String) { + self.init(text: Text(verbatim: content)) + } + + @_disfavoredOverload public init<S: StringProtocol>(_ content: S) { + self.init(text: Text(content)) + } +} + +// MARK: Creating a Text View from an Attributed String + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension AttributedText { + + public init(_ attributedContent: AttributedString) { + self.init(text: Text(attributedContent)) + } +} + +// MARK: Creating a Text View for a Date + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +extension AttributedText { + + public init(_ dates: ClosedRange<Date>) { + self.init(text: Text(dates)) + } + + public init(_ interval: DateInterval) { + self.init(text: Text(interval)) + } + + public init(_ date: Date, style: Text.DateStyle) { + self.init(text: Text(date, style: style)) + } +} + +// MARK: Creating a Text View with Formatting + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension AttributedText { + + public init<F: FormatStyle>(_ input: F.FormatInput, format: F) where F.FormatInput : Equatable, F.FormatOutput == String { + self.init(text: Text(input, format: format)) + } +} + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +extension AttributedText { + + public init<Subject: ReferenceConvertible>(_ subject: Subject, formatter: Formatter) { + self.init(text: Text(subject, formatter: formatter)) + } + + public init<Subject: NSObject>(_ subject: Subject, formatter: Formatter) { + self.init(text: Text(subject, formatter: formatter)) + } +} + +// MARK: Creating a Text View from an Image + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +extension AttributedText { + + public init(_ image: Image) { + self.init(text: Text(image)) + } +} + +// MARK: Choosing a Font + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +extension AttributedText { + + public func attributedFont(_ attributedFont: AttributedFont?) -> Self { + var modified = self + modified.text = text + .font(attributedFont?.font) + .kerning(attributedFont?.attributes.kerning ?? 0) + modified.modifiedAttributedFont = attributedFont + return modified + } + + public func font(_ font: Font?) -> Self { + var modified = self + modified.text = text.font(font) + return modified + } + + public func fontWeight(_ weight: Font.Weight?) -> Self { + var modified = self + modified.text = text.fontWeight(weight) + return modified + } +} + +// MARK: Styling the View’s Text + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +extension AttributedText { + + public func foregroundColor(_ color: Color?) -> Self { + var modified = self + modified.text = text.foregroundColor(color) + return modified + } + + public func bold() -> Self { + var modified = self + modified.text = text.bold() + return modified + } + + public func italic() -> Self { + var modified = self + modified.text = text.italic() + return modified + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + public func monospacedDigit() -> Self { + var modified = self + modified.text = text.monospacedDigit() + return modified + } + + public func strikethrough(_ active: Bool = true, color: Color? = nil) -> Self { + var modified = self + modified.text = text.strikethrough(active, color: color) + return modified + } + + public func underline(_ active: Bool = true, color: Color? = nil) -> Self { + var modified = self + modified.text = text.underline(active, color: color) + return modified + } + + public func kerning(_ kerning: CGFloat) -> Self { + var modified = self + modified.text = text.kerning(kerning) + return modified + } + + public func tracking(_ tracking: CGFloat) -> Self { + var modified = self + modified.text = text.tracking(tracking) + return modified + } + + public func baselineOffset(_ baselineOffset: CGFloat) -> Self { + var modified = self + modified.text = text.baselineOffset(baselineOffset) + return modified + } +} + +// MARK: Configuring VoiceOver + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension AttributedText { + + public func speechAlwaysIncludesPunctuation(_ value: Bool = true) -> Self { + var modified = self + modified.text = text.speechAlwaysIncludesPunctuation(value) + return modified + } + + public func speechSpellsOutCharacters(_ value: Bool = true) -> Self { + var modified = self + modified.text = text.speechSpellsOutCharacters(value) + return modified + } + + public func speechAdjustedPitch(_ value: Double) -> Self { + var modified = self + modified.text = text.speechAdjustedPitch(value) + return modified + } + + public func speechAnnouncementsQueued(_ value: Bool = true) -> Self { + var modified = self + modified.text = text.speechAnnouncementsQueued(value) + return modified + } +} + +// MARK: Providing Accessibility Information + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension AttributedText { + public func accessibilityTextContentType(_ value: AccessibilityTextContentType) -> Self { + var modified = self + modified.text = text.accessibilityTextContentType(value) + return modified + } + + public func accessibilityHeading(_ level: AccessibilityHeadingLevel) -> Self { + var modified = self + modified.text = text.accessibilityHeading(level) + return modified + } + + public func accessibilityLabel(_ label: Text) -> Self { + var modified = self + modified.text = text.accessibilityLabel(label) + return modified + } + + public func accessibilityLabel(_ labelKey: LocalizedStringKey) -> Self { + var modified = self + modified.text = text.accessibilityLabel(labelKey) + return modified + } + + public func accessibilityLabel<S: StringProtocol>(_ label: S) -> Self { + var modified = self + modified.text = text.accessibilityLabel(label) + return modified + } +} + +// MARK: Combining Text Views + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +extension AttributedText { + + public static func concatenate(_ views: [AttributedText], attributedFont: AttributedFont) -> Self { + let text = views.map(\.text).reduce(Text(verbatim: ""), +) + var attributedText: AttributedText = .init(text: text) + attributedText.modifiedAttributedFont = attributedFont + return attributedText + } + + public static func concatenate(_ views: AttributedText..., attributedFont: AttributedFont) -> Self { + return Self.concatenate(views, attributedFont: attributedFont) + } +} + +// MARK: Comparing Text Views + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +extension AttributedText: Equatable { + + public static func == (lhs: AttributedText, rhs: AttributedText) -> Bool { + return lhs.text == rhs.text + } +} diff --git a/Sources/AttributedFont/AttributedText.swift b/Sources/AttributedFont/AttributedText.swift new file mode 100644 index 0000000..f5267c6 --- /dev/null +++ b/Sources/AttributedFont/AttributedText.swift @@ -0,0 +1,27 @@ +import SwiftUI + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public struct AttributedText: View { + + @Environment(\.attributedFont) private var environmentAttributedFont: AttributedFont? + + internal var text: Text + internal var modifiedAttributedFont: AttributedFont? + + internal var attributedFont: AttributedFont? { + return modifiedAttributedFont ?? environmentAttributedFont + } + + public init(text: Text) { + self.text = text + } + + public var body: some View { + text + .font(attributedFont?.font) + .kerning(attributedFont?.attributes.kerning ?? 0) + .tracking(attributedFont?.attributes.tracking ?? 0) + .lineSpacing(attributedFont?.lineSpacing ?? 0) + .padding(.vertical, (attributedFont?.lineSpacing?.rounded()).flatMap { $0 / 2 }) + } +} diff --git a/Sources/AttributedFont/CTFontGetLineHeight.swift b/Sources/AttributedFont/CTFontGetLineHeight.swift new file mode 100644 index 0000000..e04d7e3 --- /dev/null +++ b/Sources/AttributedFont/CTFontGetLineHeight.swift @@ -0,0 +1,6 @@ +import CoreText + +@available(iOS 3.2, macOS 10.5, tvOS 9.0, watchOS 2.0, *) +public func CTFontGetLineHeight(_ font: CTFont) -> CGFloat { + return CTFontGetAscent(font) + CTFontGetDescent(font) + CTFontGetLeading(font) +} diff --git a/Sources/AttributedFont/EnvironmentValues+attributedFont.swift b/Sources/AttributedFont/EnvironmentValues+attributedFont.swift new file mode 100644 index 0000000..941bf41 --- /dev/null +++ b/Sources/AttributedFont/EnvironmentValues+attributedFont.swift @@ -0,0 +1,24 @@ +import SwiftUI + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +private struct AttributedFontKey: EnvironmentKey { + + static let defaultValue: AttributedFont? = nil +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +extension EnvironmentValues { + + public var attributedFont: AttributedFont? { + get { self[AttributedFontKey.self] } + set { self[AttributedFontKey.self] = newValue } + } +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +extension View { + + public func attributedFont(_ attributedFont: AttributedFont?) -> some View { + return self.environment(\.attributedFont, attributedFont) + } +} diff --git a/Sources/Previews/Previews.swift b/Sources/Previews/Previews.swift new file mode 100644 index 0000000..6bcbc20 --- /dev/null +++ b/Sources/Previews/Previews.swift @@ -0,0 +1,65 @@ +import SwiftUI +import AttributedFont + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +struct AttributedText_Previews: PreviewProvider { + + static let body = + "Here's to the crazy ones. The misfits. The rebels. " + + "The troublemakers. The round pegs in the square " + + "holes. The ones who see things differently. They're " + + "not fond of rules. And they have no respect for the " + + "status quo. You can praise them, disagree with them, " + + "quote them, disbelieve them, glorify or vilify them. " + + "About the only thing you can't do is ignore them. " + + "Because they change things." + + static var previews: some View { + VStack { + AttributedText(body) + Button<AttributedText>("Read More", action: {}) + .buttonStyle(FilledButtonStyle()) + } + .attributedFont( + .custom( + "Avenir Next", + fixedSize: 16, + attributes: .init( + kerning: 0, + lineHeightMultiple: 1.5 + ) + ) + ) + .padding() + .previewLayout(.sizeThatFits) + } +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +private extension Button where Label == AttributedText { + + init(_ titleKey: LocalizedStringKey, action: @escaping () -> Void) { + self.init(action: action) { + AttributedText(titleKey) + } + } + + init<S: StringProtocol>(_ title: S, action: @escaping () -> Void) { + self.init(action: action) { + AttributedText(title) + } + } +} + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +private struct FilledButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundColor(.white) + .padding(8) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 12).fill(.blue) + ) + } +} diff --git a/Tests/AttributedFontTests/AttributedFontTests.swift b/Tests/AttributedFontTests/AttributedFontTests.swift deleted file mode 100644 index f69b7e1..0000000 --- a/Tests/AttributedFontTests/AttributedFontTests.swift +++ /dev/null @@ -1,11 +0,0 @@ -import XCTest -@testable import AttributedFont - -final class AttributedFontTests: XCTestCase { - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(AttributedFont().text, "Hello, World!") - } -}