mirror of
https://github.com/kean/Pulse.git
synced 2026-05-30 21:07:33 +00:00
160 lines
4.7 KiB
Swift
160 lines
4.7 KiB
Swift
// The MIT License (MIT)
|
||
//
|
||
// Copyright (c) 2020–2023 Alexander Grebenyuk (github.com/kean).
|
||
|
||
import Foundation
|
||
import SwiftUI
|
||
|
||
/// Manages text attributes.
|
||
final class TextHelper {
|
||
private var cachedAttributes: [AttributesKey: [NSAttributedString.Key: Any]] = [:]
|
||
private var cachedFonts: [TextStyle: UXFont] = [:]
|
||
|
||
init() {}
|
||
|
||
func attributes(
|
||
role: TextRole,
|
||
style: TextFontStyle = .proportional,
|
||
weight: UXFont.Weight = .regular,
|
||
width: TextWidth = .standard,
|
||
color: UXColor? = .label
|
||
) -> [NSAttributedString.Key: Any] {
|
||
attributes(style: .init(role: role, style: style, weight: weight, width: width), color: color)
|
||
}
|
||
|
||
private(set) lazy var spacerAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: scaled(font: UXFont.systemFont(ofSize: 10))
|
||
]
|
||
|
||
func attributes(style: TextStyle, color: UXColor?) -> [NSAttributedString.Key: Any] {
|
||
let key = AttributesKey(textStyle: style, color: color)
|
||
if let attributes = cachedAttributes[key] {
|
||
return attributes
|
||
}
|
||
let attributes = makeAttributes(style: style, color: color)
|
||
cachedAttributes[key] = attributes
|
||
return attributes
|
||
}
|
||
|
||
func font(style: TextStyle) -> UXFont {
|
||
if let font = cachedFonts[style] {
|
||
return font
|
||
}
|
||
let font = makeFont(style: style)
|
||
cachedFonts[style] = font
|
||
return font
|
||
}
|
||
|
||
private let titleParagraphStyle: NSParagraphStyle = {
|
||
let paragraphStyle = NSMutableParagraphStyle()
|
||
paragraphStyle.lineSpacing = -6
|
||
return paragraphStyle
|
||
}()
|
||
|
||
private let bodyParagraphStyle: NSParagraphStyle = {
|
||
let paragraphStyle = NSMutableParagraphStyle()
|
||
paragraphStyle.lineSpacing = 3
|
||
return paragraphStyle
|
||
}()
|
||
|
||
private func makeAttributes(style: TextStyle, color: UXColor?) -> [NSAttributedString.Key: Any] {
|
||
var attributes: [NSAttributedString.Key: Any] = [:]
|
||
let font = self.font(style: style)
|
||
attributes[.font] = font
|
||
attributes[.paragraphStyle] = style.role == .title ? titleParagraphStyle : bodyParagraphStyle
|
||
if style.width == .condensed {
|
||
attributes[.kern] = -0.4
|
||
} else if style.style == .monospaced {
|
||
attributes[.kern] = -0.3
|
||
}
|
||
attributes[.foregroundColor] = color
|
||
if style.role == .subheadline {
|
||
attributes[.subheadline] = true
|
||
}
|
||
return attributes
|
||
}
|
||
|
||
private func makeFont(style: TextStyle) -> UXFont {
|
||
var size: CGFloat
|
||
let body2Size = (0.9 * getPreferredFontSize(for: .body)).rounded()
|
||
switch style.role {
|
||
#if os(watchOS)
|
||
case .title: size = getPreferredFontSize(for: .title2)
|
||
#else
|
||
case .title: size = getPreferredFontSize(for: .title1)
|
||
#endif
|
||
case .subheadline:
|
||
#if os(macOS)
|
||
size = (0.9 * body2Size).rounded()
|
||
#else
|
||
size = (0.84 * body2Size).rounded()
|
||
#endif
|
||
case .body: size = getPreferredFontSize(for: .body)
|
||
case .body2: size = body2Size
|
||
}
|
||
#if !os(macOS)
|
||
if style.style == .monospaced { size -= 2 } // Appears larger than regular
|
||
#endif
|
||
return scaled(font: {
|
||
switch style.style {
|
||
case .proportional: return .systemFont(ofSize: size, weight: style.weight)
|
||
case .monospaced: return .monospacedSystemFont(ofSize: size, weight: style.weight)
|
||
case .monospacedDigital: return .monospacedDigitSystemFont(ofSize: size, weight: style.weight)
|
||
}
|
||
}())
|
||
}
|
||
|
||
private func scaled(font: UXFont) -> UXFont {
|
||
#if os(iOS) || os(tvOS) || os(watchOS)
|
||
return UIFontMetrics.default.scaledFont(for: font)
|
||
#else
|
||
return font
|
||
#endif
|
||
}
|
||
|
||
private struct AttributesKey: Hashable {
|
||
let textStyle: TextStyle
|
||
let color: UXColor?
|
||
}
|
||
}
|
||
|
||
struct TextStyle: Hashable {
|
||
var role: TextRole
|
||
var style: TextFontStyle = .proportional
|
||
var weight: UXFont.Weight = .regular
|
||
var width: TextWidth = .standard
|
||
}
|
||
|
||
enum TextRole {
|
||
/// Large title.
|
||
case title
|
||
/// Section headline (small).
|
||
///
|
||
/// Font size: iOS 12, macOS 10, tvOS 21, watchOS 11
|
||
case subheadline
|
||
/// Regular-sized body.
|
||
///
|
||
/// Font size: iOS 17, macOS 13, tvOS 29, watchOS 16.
|
||
case body
|
||
/// Smaller body for console and other views where information has to be
|
||
/// condensed.
|
||
///
|
||
/// Font size: iOS 15, macOS 12, tvOS 26, watchOS 14.
|
||
case body2
|
||
}
|
||
|
||
enum TextFontStyle {
|
||
case proportional
|
||
case monospaced
|
||
case monospacedDigital
|
||
}
|
||
|
||
enum TextWidth {
|
||
case condensed
|
||
case standard
|
||
}
|
||
|
||
private func getPreferredFontSize(for style: UXFont.TextStyle) -> CGFloat {
|
||
UXFont.preferredFont(forTextStyle: style).fontDescriptor.pointSize
|
||
}
|