├── .clang-format ├── .editorconfig ├── .gitignore ├── .swift-format ├── Example └── GlyphixTextExample │ ├── GlyphixTextExample.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ └── contents.xcworkspacedata │ └── GlyphixTextExample │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── GlyphixTextExample.entitlements │ ├── Info.plist │ ├── SceneDelegate.swift │ ├── SettingsPanel.swift │ └── ViewController.swift ├── LICENSE ├── Package.swift ├── README.md └── Sources ├── GlyphixHook ├── GTFHook.mm └── include │ ├── GTFHook.h │ └── GlyphixHook.h ├── GlyphixTextFx ├── AnimationState.swift ├── ArrayContainer.swift ├── Filter │ ├── CAFilter.swift │ ├── ColorAddFilter.swift │ └── GaussianBlurFilter.swift ├── GlyphixText │ ├── GlyphixText+Animation.swift │ ├── GlyphixText+Blur.swift │ ├── GlyphixText+Font.swift │ ├── GlyphixText+TextColor.swift │ └── GlyphixText.swift ├── GlyphixTextLabel.swift ├── GlyphixTextLayer.swift ├── Platform.swift └── RGBColor.swift └── GlyphixTypesetter ├── PlacedGlyph.swift ├── Platform.swift └── TextLayout.swift /.clang-format: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/clang-format.json 2 | --- 3 | Language: ObjC 4 | AccessModifierOffset: -4 5 | AlignAfterOpenBracket: BlockIndent 6 | AlignArrayOfStructures: Right 7 | AlignConsecutiveAssignments: 8 | Enabled: false 9 | AcrossEmptyLines: false 10 | AcrossComments: false 11 | AlignCompound: false 12 | PadOperators: false 13 | AlignConsecutiveBitFields: 14 | Enabled: false 15 | AcrossEmptyLines: false 16 | AcrossComments: false 17 | AlignCompound: false 18 | PadOperators: false 19 | AlignConsecutiveDeclarations: 20 | Enabled: false 21 | AcrossEmptyLines: false 22 | AcrossComments: false 23 | AlignCompound: false 24 | PadOperators: false 25 | AlignConsecutiveMacros: 26 | Enabled: false 27 | AcrossEmptyLines: false 28 | AcrossComments: false 29 | AlignCompound: false 30 | PadOperators: false 31 | AlignConsecutiveShortCaseStatements: 32 | Enabled: false 33 | AcrossEmptyLines: false 34 | AcrossComments: false 35 | AlignCaseColons: false 36 | AlignEscapedNewlines: DontAlign 37 | AlignOperands: Align 38 | AlignTrailingComments: 39 | Kind: Always 40 | OverEmptyLines: 0 41 | AllowAllArgumentsOnNextLine: true 42 | AllowAllParametersOfDeclarationOnNextLine: true 43 | AllowShortBlocksOnASingleLine: Never 44 | AllowShortCaseLabelsOnASingleLine: false 45 | AllowShortEnumsOnASingleLine: true 46 | AllowShortFunctionsOnASingleLine: Empty 47 | AllowShortIfStatementsOnASingleLine: Never 48 | AllowShortLambdasOnASingleLine: All 49 | AllowShortLoopsOnASingleLine: false 50 | AlwaysBreakAfterDefinitionReturnType: None 51 | AlwaysBreakAfterReturnType: None 52 | AlwaysBreakBeforeMultilineStrings: false 53 | AlwaysBreakTemplateDeclarations: Yes 54 | AttributeMacros: 55 | - __capability 56 | BinPackArguments: false 57 | BinPackParameters: false 58 | BitFieldColonSpacing: Both 59 | BraceWrapping: 60 | AfterCaseLabel: false 61 | AfterClass: false 62 | AfterControlStatement: Never 63 | AfterEnum: false 64 | AfterExternBlock: false 65 | AfterFunction: false 66 | AfterNamespace: false 67 | AfterObjCDeclaration: false 68 | AfterStruct: false 69 | AfterUnion: false 70 | BeforeCatch: false 71 | BeforeElse: false 72 | BeforeLambdaBody: false 73 | BeforeWhile: false 74 | IndentBraces: false 75 | SplitEmptyFunction: false 76 | SplitEmptyRecord: false 77 | SplitEmptyNamespace: false 78 | BreakAfterAttributes: Never 79 | BreakAfterJavaFieldAnnotations: false 80 | BreakArrays: true 81 | BreakBeforeBinaryOperators: NonAssignment 82 | BreakBeforeConceptDeclarations: Always 83 | BreakBeforeBraces: Custom 84 | BreakBeforeInlineASMColon: OnlyMultiline 85 | BreakBeforeTernaryOperators: true 86 | BreakConstructorInitializers: BeforeComma 87 | BreakInheritanceList: BeforeColon 88 | BreakStringLiterals: true 89 | ColumnLimit: 180 90 | CommentPragmas: "^ IWYU pragma:" 91 | CompactNamespaces: false 92 | ConstructorInitializerIndentWidth: 4 93 | ContinuationIndentWidth: 4 94 | Cpp11BracedListStyle: true 95 | DerivePointerAlignment: true 96 | DisableFormat: false 97 | EmptyLineAfterAccessModifier: Never 98 | EmptyLineBeforeAccessModifier: LogicalBlock 99 | ExperimentalAutoDetectBinPacking: false 100 | FixNamespaceComments: true 101 | IfMacros: 102 | - KJ_IF_MAYBE 103 | IncludeBlocks: Preserve 104 | IncludeCategories: 105 | - Regex: "^ 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/GlyphixTextExample/GlyphixTextExample/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/3/7. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import UIKit 7 | 8 | @main 9 | final class AppDelegate: UIResponder, UIApplicationDelegate { 10 | 11 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 12 | // Override point for customization after application launch. 13 | return true 14 | } 15 | 16 | // MARK: UISceneSession Lifecycle 17 | 18 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 19 | // Called when a new scene session is being created. 20 | // Use this method to select a configuration to create the new scene with. 21 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 22 | } 23 | 24 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 25 | // Called when the user discards a scene session. 26 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 27 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Example/GlyphixTextExample/GlyphixTextExample/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Example/GlyphixTextExample/GlyphixTextExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Example/GlyphixTextExample/GlyphixTextExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Example/GlyphixTextExample/GlyphixTextExample/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Example/GlyphixTextExample/GlyphixTextExample/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Example/GlyphixTextExample/GlyphixTextExample/GlyphixTextExample.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/GlyphixTextExample/GlyphixTextExample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | UIApplicationSceneManifest 6 | 7 | UIApplicationSupportsMultipleScenes 8 | 9 | UISceneConfigurations 10 | 11 | UIWindowSceneSessionRoleApplication 12 | 13 | 14 | UISceneConfigurationName 15 | Default Configuration 16 | UISceneDelegateClassName 17 | $(PRODUCT_MODULE_NAME).SceneDelegate 18 | UISceneStoryboardFile 19 | Main 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Example/GlyphixTextExample/GlyphixTextExample/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/3/7. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import UIKit 7 | 8 | final class SceneDelegate: UIResponder, UIWindowSceneDelegate { 9 | 10 | var window: UIWindow? 11 | 12 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 13 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 14 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 15 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 16 | guard let _ = (scene as? UIWindowScene) else { return } 17 | } 18 | 19 | func sceneDidDisconnect(_ scene: UIScene) { 20 | // Called as the scene is being released by the system. 21 | // This occurs shortly after the scene enters the background, or when its session is discarded. 22 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 23 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 24 | } 25 | 26 | func sceneDidBecomeActive(_ scene: UIScene) { 27 | // Called when the scene has moved from an inactive state to an active state. 28 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 29 | } 30 | 31 | func sceneWillResignActive(_ scene: UIScene) { 32 | // Called when the scene will move from an active state to an inactive state. 33 | // This may occur due to temporary interruptions (ex. an incoming phone call). 34 | } 35 | 36 | func sceneWillEnterForeground(_ scene: UIScene) { 37 | // Called as the scene transitions from the background to the foreground. 38 | // Use this method to undo the changes made on entering the background. 39 | } 40 | 41 | func sceneDidEnterBackground(_ scene: UIScene) { 42 | // Called as the scene transitions from the foreground to the background. 43 | // Use this method to save data, release shared resources, and store enough scene-specific state information 44 | // to restore the scene back to its current state. 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Example/GlyphixTextExample/GlyphixTextExample/SettingsPanel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/3/7. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import GlyphixTextFx 7 | import SwiftUI 8 | 9 | final class LabelConfiguration: ObservableObject { 10 | 11 | @Published var fontSize: Int = 36 12 | @Published var textColor: UIColor = .label 13 | @Published var numberOfLines: Int = 1 14 | @Published var countsDown: Bool = false 15 | @Published var alignment: GlyphixTextFx.TextAlignment = .leading 16 | @Published var lineBreakMode: NSLineBreakMode = .byTruncatingTail 17 | 18 | @Published var isAnimationEnabled: Bool = true 19 | @Published var isBlurEffectEnabled: Bool = true 20 | @Published var isSmoothRenderingEnabled: Bool = false 21 | } 22 | 23 | extension GlyphixTextFx.TextAlignment: @retroactive CustomStringConvertible { 24 | public var description: String { 25 | switch self { 26 | case .center: 27 | "Center" 28 | case .leading: 29 | "Leading" 30 | case .trailing: 31 | "Trailing" 32 | } 33 | } 34 | } 35 | 36 | extension NSLineBreakMode: @retroactive CustomStringConvertible { 37 | public var description: String { 38 | switch self { 39 | case .byWordWrapping: 40 | ".byWordWrapping" 41 | case .byCharWrapping: 42 | ".byCharWrapping" 43 | case .byClipping: 44 | ".byClipping" 45 | case .byTruncatingHead: 46 | ".byTruncatingHead" 47 | case .byTruncatingTail: 48 | ".byTruncatingTail" 49 | case .byTruncatingMiddle: 50 | ".byTruncatingMiddle" 51 | @unknown default: 52 | fatalError() 53 | } 54 | } 55 | } 56 | 57 | struct SettingsPanel: View { 58 | 59 | @StateObject var configuration: LabelConfiguration 60 | 61 | private let textColors: [UIColor] = [ 62 | .label, 63 | .systemRed, 64 | .systemOrange, 65 | .systemYellow, 66 | .systemGreen, 67 | .systemBlue, 68 | .systemCyan, 69 | .systemPurple, 70 | ] 71 | 72 | var body: some View { 73 | List { 74 | Section { 75 | Stepper(value: $configuration.fontSize, in: 12...48) { 76 | HStack { 77 | Text("Font Size") 78 | Spacer() 79 | Text("\(configuration.fontSize)") 80 | .font(.system(.body, design: .monospaced)) 81 | .foregroundStyle(.secondary) 82 | } 83 | } 84 | VStack(alignment: .leading, spacing: 12) { 85 | Text("Text Color") 86 | .padding(.horizontal, 20) 87 | ScrollView(.horizontal, showsIndicators: false) { 88 | LazyHStack(spacing: 14) { 89 | Color.clear 90 | .frame(width: 10, height: 30) 91 | ForEach(textColors, id: \.self) { color in 92 | let isSelected = color == configuration.textColor 93 | Button { 94 | withAnimation(.spring(duration: 0.24)) { 95 | configuration.textColor = color 96 | } 97 | } label: { 98 | Circle() 99 | .frame(width: 30, height: 30) 100 | .foregroundColor(Color(color)) 101 | .overlay { 102 | if isSelected { 103 | Image(systemName: "checkmark") 104 | .font(.system(size: 14, weight: .bold)) 105 | .foregroundColor(color == .label ? Color(UIColor.systemBackground) : .white) 106 | .clipShape(Circle()) 107 | .transition(.scale) 108 | } 109 | } 110 | } 111 | } 112 | Color.clear 113 | .frame(width: 10, height: 30) 114 | } 115 | } 116 | } 117 | .listRowInsets(.init()) 118 | .padding(.vertical, 12) 119 | 120 | Stepper(value: $configuration.numberOfLines, in: 0...50) { 121 | let numberOfLines = configuration.numberOfLines 122 | if numberOfLines > 0 { 123 | HStack { 124 | Text("Max Lines") 125 | Spacer() 126 | Text("\(numberOfLines)") 127 | .font(.system(.body, design: .monospaced)) 128 | .foregroundStyle(.secondary) 129 | } 130 | } else { 131 | Text("Unlimited Lines") 132 | } 133 | } 134 | HStack { 135 | Text("Transition Direction") 136 | Spacer() 137 | Menu { 138 | Button("Upward") { 139 | configuration.countsDown = false 140 | } 141 | Button("Downward") { 142 | configuration.countsDown = true 143 | } 144 | } label: { 145 | Text(configuration.countsDown ? "Downward" : "Upward") 146 | } 147 | } 148 | HStack { 149 | Text("Text Alignment") 150 | Spacer() 151 | Menu { 152 | ForEach(GlyphixTextFx.TextAlignment.allCases, id: \.self) { alignment in 153 | Button(alignment.description) { 154 | configuration.alignment = alignment 155 | } 156 | } 157 | } label: { 158 | Text(configuration.alignment.description) 159 | } 160 | } 161 | HStack { 162 | Text("Line Break Mode") 163 | Spacer() 164 | Menu { 165 | Button("Word Wrap") { 166 | configuration.lineBreakMode = .byWordWrapping 167 | } 168 | Button("Character Wrap") { 169 | configuration.lineBreakMode = .byCharWrapping 170 | } 171 | Button("Clipping") { 172 | configuration.lineBreakMode = .byClipping 173 | } 174 | Button("Truncate Head") { 175 | configuration.lineBreakMode = .byTruncatingHead 176 | } 177 | Button("Truncate Middle") { 178 | configuration.lineBreakMode = .byTruncatingMiddle 179 | } 180 | Button("Truncate Tail") { 181 | configuration.lineBreakMode = .byTruncatingTail 182 | } 183 | } label: { 184 | Text(configuration.lineBreakMode.description) 185 | } 186 | } 187 | } header: { 188 | Text("General") 189 | } 190 | Section { 191 | Toggle("Blur", isOn: $configuration.isBlurEffectEnabled) 192 | Toggle("Animations", isOn: $configuration.isAnimationEnabled) 193 | Toggle("Smooth Rendering", isOn: $configuration.isSmoothRenderingEnabled) 194 | } header: { 195 | Text("Effects") 196 | } 197 | } 198 | } 199 | } 200 | 201 | #Preview { 202 | SettingsPanel(configuration: .init()) 203 | } 204 | -------------------------------------------------------------------------------- /Example/GlyphixTextExample/GlyphixTextExample/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/3/7. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import Combine 7 | import GlyphixTextFx 8 | import SwiftUI 9 | import UIKit 10 | 11 | final class ViewController: UIViewController { 12 | 13 | private let labelFont: UIFont = .systemFont(ofSize: 36, weight: .bold) 14 | private lazy var glyphixLabel: GlyphixTextLabel = .init() 15 | private lazy var settingsPanelController: UIHostingController = .init(rootView: SettingsPanel(configuration: self.labelConfiguration)) 16 | private lazy var labelConfiguration: LabelConfiguration = .init() 17 | private lazy var segmentedControl: UISegmentedControl = .init() 18 | 19 | private var cancellables: Set = .init() 20 | private var seconds: Int = 0 21 | private var clockRepresentation: String { 22 | let minutes = seconds / 60 23 | let seconds = self.seconds % 60 24 | return String(format: "%02d:%02d", minutes, seconds) 25 | } 26 | 27 | override func viewDidLoad() { 28 | super.viewDidLoad() 29 | 30 | segmentedControl.insertSegment(withTitle: "Seconds", at: 0, animated: true) 31 | segmentedControl.insertSegment(withTitle: "Multi-lines Text", at: 1, animated: true) 32 | segmentedControl.selectedSegmentIndex = 0 33 | segmentedControl.addTarget(self, action: #selector(handleTextTypeChanged(_:)), for: .valueChanged) 34 | view.addSubview(segmentedControl) 35 | 36 | glyphixLabel.font = labelFont 37 | glyphixLabel.text = clockRepresentation 38 | view.addSubview(glyphixLabel) 39 | 40 | addChild(settingsPanelController) 41 | view.addSubview(settingsPanelController.view) 42 | settingsPanelController.didMove(toParent: self) 43 | settingsPanelController.view.layer.cornerRadius = 12 44 | settingsPanelController.view.layer.cornerCurve = .continuous 45 | settingsPanelController.view.clipsToBounds = true 46 | 47 | Timer.publish(every: 1, on: .main, in: .common) 48 | .autoconnect() 49 | .sink { [unowned self] _ in 50 | seconds += 1 51 | if segmentedControl.selectedSegmentIndex == 0 { 52 | glyphixLabel.text = clockRepresentation 53 | } 54 | } 55 | .store(in: &cancellables) 56 | 57 | labelConfiguration.$fontSize 58 | .sink { [unowned self] fontSize in 59 | glyphixLabel.font = labelFont.withSize(CGFloat(fontSize)) 60 | } 61 | .store(in: &cancellables) 62 | 63 | labelConfiguration.$textColor 64 | .sink { [unowned self] textColor in 65 | glyphixLabel.textColor = textColor 66 | } 67 | .store(in: &cancellables) 68 | 69 | labelConfiguration.$numberOfLines 70 | .sink { [unowned self] numberOfLines in 71 | glyphixLabel.numberOfLines = numberOfLines 72 | } 73 | .store(in: &cancellables) 74 | 75 | labelConfiguration.$countsDown 76 | .sink { [unowned self] countsDown in 77 | glyphixLabel.countsDown = countsDown 78 | } 79 | .store(in: &cancellables) 80 | 81 | labelConfiguration.$alignment 82 | .sink { [unowned self] alignment in 83 | glyphixLabel.textAlignment = alignment 84 | } 85 | .store(in: &cancellables) 86 | 87 | labelConfiguration.$isSmoothRenderingEnabled 88 | .sink { [unowned self] isSmoothRenderingEnabled in 89 | glyphixLabel.isSmoothRenderingEnabled = isSmoothRenderingEnabled 90 | } 91 | .store(in: &cancellables) 92 | 93 | labelConfiguration.$lineBreakMode 94 | .sink { [unowned self] lineBreakMode in 95 | glyphixLabel.lineBreakMode = lineBreakMode 96 | } 97 | .store(in: &cancellables) 98 | 99 | labelConfiguration.$isAnimationEnabled 100 | .sink { [unowned self] isAnimationEnabled in 101 | glyphixLabel.disablesAnimations = !isAnimationEnabled 102 | } 103 | .store(in: &cancellables) 104 | 105 | labelConfiguration.$isBlurEffectEnabled 106 | .sink { [unowned self] isBlurEffectEnabled in 107 | glyphixLabel.isBlurEffectEnabled = isBlurEffectEnabled 108 | } 109 | .store(in: &cancellables) 110 | } 111 | 112 | override func viewWillLayoutSubviews() { 113 | super.viewWillLayoutSubviews() 114 | 115 | let safeAreaInsets = view.safeAreaInsets 116 | 117 | let segmentedControlSize = segmentedControl.intrinsicContentSize 118 | segmentedControl.frame = .init( 119 | x: view.bounds.width / 2 - segmentedControlSize.width / 2, 120 | y: safeAreaInsets.top, 121 | width: segmentedControlSize.width, 122 | height: segmentedControlSize.height 123 | ) 124 | 125 | glyphixLabel.frame = .init( 126 | x: 0, 127 | y: segmentedControl.frame.maxY + 8, 128 | width: view.bounds.width, 129 | height: view.bounds.height / 2 - segmentedControl.frame.maxY - 8 130 | ) 131 | settingsPanelController.view.frame = .init( 132 | x: 0, 133 | y: view.bounds.height / 2, 134 | width: view.bounds.width, 135 | height: view.bounds.height / 2 136 | ) 137 | } 138 | 139 | @objc 140 | private func handleTextTypeChanged(_ sender: UISegmentedControl) { 141 | switch sender.selectedSegmentIndex { 142 | case 0: 143 | glyphixLabel.text = clockRepresentation 144 | default: 145 | glyphixLabel.text = """ 146 | Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. 147 | """ 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 ktiays 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "GlyphixTextFx", 8 | platforms: [ 9 | .macOS(.v11), 10 | .iOS(.v13), 11 | .watchOS(.v5), 12 | .tvOS(.v13), 13 | ], 14 | products: [ 15 | .library( 16 | name: "GlyphixTextFx", 17 | targets: ["GlyphixTextFx"] 18 | ) 19 | ], 20 | dependencies: [ 21 | .package(url: "https://github.com/ktiays/Respring", from: "1.0.0"), 22 | .package(url: "https://github.com/unixzii/Choreographer", from: "0.1.0"), 23 | .package(url: "https://github.com/ktiays/With", from: "2.0.0"), 24 | ], 25 | targets: [ 26 | .target(name: "GlyphixTypesetter", dependencies: ["With"]), 27 | .target(name: "GlyphixHook"), 28 | .target( 29 | name: "GlyphixTextFx", 30 | dependencies: ["Respring", "Choreographer", "With", "GlyphixTypesetter", "GlyphixHook"] 31 | ), 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GlyphixTextFx 2 | 3 | A label component that provides content transition animations like SwiftUI's `numericText`. 4 | 5 | > [!NOTE] 6 | > The library originally named `NumericTransitionLabel` is now renamed to `GlyphixTextFx`. 7 | 8 | ## Features 9 | 10 | - Per-character smooth animations when text changes 11 | - Customizable transition direction (upward or downward) 12 | - Multiple text alignment options (left, center, right) 13 | - Multi-line text support with configurable line break modes 14 | - Optional blur effects for enhanced visual transitions 15 | - Smooth rendering option for improved text appearance 16 | - Works with both UIKit/AppKit and SwiftUI 17 | 18 | ## Preview 19 | 20 | https://github.com/user-attachments/assets/c180fd39-870b-4d0e-be37-73d9053f125b 21 | 22 | You can find the example project in the `Example` directory. 23 | 24 | ## Supported Platforms 25 | 26 | - iOS 13.0+ 27 | - macOS 11.0+ 28 | 29 | ## Installation 30 | 31 | ### Swift Package Manager 32 | 33 | Add GlyphixTextFx to your Swift package dependencies: 34 | 35 | ```swift 36 | dependencies: [ 37 | .package(url: "https://github.com/ktiays/GlyphixTextFx.git", from: "2.0.0") 38 | ] 39 | ``` 40 | 41 | ## Usage 42 | 43 | ### UIKit/AppKit 44 | 45 | ```swift 46 | import GlyphixTextFx 47 | import UIKit 48 | 49 | let label = GlyphixTextLabel() 50 | label.font = .systemFont(ofSize: 36, weight: .bold) 51 | label.textColor = .systemBlue 52 | label.countsDown = true 53 | label.text = "1234567890" 54 | ``` 55 | 56 | ### SwiftUI 57 | 58 | ```swift 59 | import SwiftUI 60 | import GlyphixTextFx 61 | 62 | struct ContentView: View { 63 | var body: some View { 64 | GlyphixText("1234567890") 65 | .glyphixLabelFont(.systemFont(ofSize: 36, weight: .bold)) 66 | .glyphixLabelColor(.systemBlue) 67 | } 68 | } 69 | ``` 70 | 71 | See the component documentation comments for other related usage details. 72 | 73 | ## Performance Considerations 74 | 75 | > [!IMPORTANT] 76 | > The blur effect can significantly impact performance, especially with longer text. It's recommended to disable blur effects for better performance when displaying large amounts of text: 77 | 78 | ```swift 79 | // Disable blur for better performance with long text. 80 | label.isBlurEffectEnabled = false 81 | ``` 82 | 83 | ## License 84 | GlyphixTextFx is available under the MIT license. See the LICENSE file for more info. 85 | -------------------------------------------------------------------------------- /Sources/GlyphixHook/GTFHook.mm: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/3/1. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | #import 7 | #import 8 | #import 9 | 10 | #import "GTFHook.h" 11 | 12 | @implementation NSObject (GTFHook) 13 | 14 | - (void)gtf_addInstanceMethod:(SEL)selector withBlock:(void (^)(id))block { 15 | IMP imp = imp_implementationWithBlock(^(id object, SEL selector) { 16 | block(object); 17 | }); 18 | class_addMethod(self.class, selector, imp, "v@:"); 19 | } 20 | 21 | - (void)gtf_addInstanceMethod:(SEL)selector withCGRectArgBlock:(void (^)(id, CGRect))block { 22 | IMP imp = imp_implementationWithBlock(^(id object, CGRect arg) { 23 | block(object, arg); 24 | }); 25 | std::string type = std::string("v@:") + @encode(CGRect); 26 | class_addMethod(self.class, selector, imp, type.c_str()); 27 | } 28 | 29 | - (void)gtf_addInstanceMethod:(SEL)selector withBlockReturnsBoolean:(BOOL (^)(id))block { 30 | IMP imp = imp_implementationWithBlock(^(id object) { 31 | return block(object); 32 | }); 33 | class_addMethod(self.class, selector, imp, "c@:"); 34 | } 35 | 36 | - (void)gtf_addInstanceMethod:(SEL)selector withBlockReturnsCGFloat:(CGFloat (^)(id))block { 37 | IMP imp = imp_implementationWithBlock(^(id object) { 38 | return block(object); 39 | }); 40 | class_addMethod(self.class, selector, imp, "f@:"); 41 | } 42 | 43 | - (void)gtf_invokeSuperForSelector:(SEL)selector { 44 | struct objc_super superStruct = { 45 | .receiver = self, 46 | .super_class = class_getSuperclass(self.class) 47 | }; 48 | ((void (*)(struct objc_super *, SEL)) objc_msgSendSuper)(&superStruct, selector); 49 | } 50 | 51 | - (void)gtf_invokeSuperForSelector:(SEL)selector withRect:(CGRect)rect { 52 | struct objc_super superStruct = { 53 | .receiver = self, 54 | .super_class = class_getSuperclass(self.class) 55 | }; 56 | ((void (*)(struct objc_super *, SEL, CGRect)) objc_msgSendSuper)(&superStruct, selector, rect); 57 | } 58 | 59 | - (BOOL)gtf_invokeSuperForSelectorReturnsBoolean:(SEL)selector { 60 | struct objc_super superStruct = { 61 | .receiver = self, 62 | .super_class = class_getSuperclass(self.class) 63 | }; 64 | return ((BOOL (*)(struct objc_super *, SEL)) objc_msgSendSuper)(&superStruct, selector); 65 | } 66 | 67 | - (CGFloat)gtf_invokeSuperForSelectorReturnsCGFloat:(SEL)selector { 68 | struct objc_super superStruct = { 69 | .receiver = self, 70 | .super_class = class_getSuperclass(self.class) 71 | }; 72 | return ((CGFloat (*)(struct objc_super *, SEL)) objc_msgSendSuper)(&superStruct, selector); 73 | } 74 | 75 | - (BOOL)gtf_invokeSuperForSelector:(SEL)selector withBooleanArgReturnsBoolean:(BOOL)arg { 76 | struct objc_super superStruct = { 77 | .receiver = self, 78 | .super_class = class_getSuperclass(self.class) 79 | }; 80 | return ((BOOL (*)(struct objc_super *, SEL, BOOL)) objc_msgSendSuper)(&superStruct, selector, arg); 81 | } 82 | 83 | - (id)gtf_getObjectIvar:(NSString *)ivarName { 84 | Ivar ivar = class_getInstanceVariable(self.class, ivarName.UTF8String); 85 | return object_getIvar(self, ivar); 86 | } 87 | 88 | - (IMP)gtf_getImplementationForSelector:(SEL)selector { 89 | Method method = class_getInstanceMethod(self.class, selector); 90 | return method_getImplementation(method); 91 | } 92 | 93 | @end 94 | -------------------------------------------------------------------------------- /Sources/GlyphixHook/include/GTFHook.h: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/3/1. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | #import 7 | 8 | @interface NSObject (GTFHook) 9 | 10 | - (void)gtf_addInstanceMethod:(SEL)selector withBlock:(void (^)(NSObject *))block; 11 | - (void)gtf_addInstanceMethod:(SEL)selector withCGRectArgBlock:(void (^)(NSObject *, CGRect))block; 12 | - (void)gtf_addInstanceMethod:(SEL)selector withBlockReturnsCGFloat:(CGFloat (^)(NSObject *))block; 13 | - (void)gtf_addInstanceMethod:(SEL)selector withBlockReturnsBoolean:(BOOL (^)(NSObject *))block; 14 | 15 | - (void)gtf_invokeSuperForSelector:(SEL)selector; 16 | - (BOOL)gtf_invokeSuperForSelectorReturnsBoolean:(SEL)selector; 17 | - (CGFloat)gtf_invokeSuperForSelectorReturnsCGFloat:(SEL)selector; 18 | - (void)gtf_invokeSuperForSelector:(SEL)selector withRect:(CGRect)rect; 19 | - (BOOL)gtf_invokeSuperForSelector:(SEL)selector withBooleanArgReturnsBoolean:(BOOL)arg; 20 | 21 | - (id)gtf_getObjectIvar:(NSString *)ivarName; 22 | 23 | - (IMP)gtf_getImplementationForSelector:(SEL)selector; 24 | 25 | @end 26 | -------------------------------------------------------------------------------- /Sources/GlyphixHook/include/GlyphixHook.h: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2025/3/4. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | #import "GTFHook.h" 7 | -------------------------------------------------------------------------------- /Sources/GlyphixTextFx/AnimationState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2024/11/17. 3 | // Copyright (c) 2024 ktiays. All rights reserved. 4 | // 5 | 6 | import QuartzCore 7 | import Respring 8 | 9 | struct AnimationState: VectorArithmetic where T: VectorArithmetic { 10 | var value: T 11 | var velocity: T 12 | var target: T 13 | 14 | static var zero: Self { 15 | .init(value: .zero, velocity: .zero, target: .zero) 16 | } 17 | 18 | static func - (lhs: Self, rhs: Self) -> Self { 19 | .init( 20 | value: lhs.value - rhs.value, 21 | velocity: lhs.velocity - rhs.velocity, 22 | target: lhs.target 23 | ) 24 | } 25 | 26 | static func + (lhs: Self, rhs: Self) -> Self { 27 | .init( 28 | value: lhs.value + rhs.value, 29 | velocity: lhs.velocity + rhs.velocity, 30 | target: lhs.target 31 | ) 32 | } 33 | 34 | mutating func scale(by rhs: Double) { 35 | value.scale(by: rhs) 36 | velocity.scale(by: rhs) 37 | } 38 | 39 | var magnitudeSquared: Double { 40 | value.magnitudeSquared + velocity.magnitudeSquared 41 | } 42 | } 43 | 44 | extension AnimationState where T: ApproximatelyEqual { 45 | var isCompleted: Bool { 46 | T.approximatelyEqual(value, target) && T.approximatelyEqual(velocity, .zero) 47 | } 48 | } 49 | 50 | extension CGRect: @retroactive AdditiveArithmetic {} 51 | extension CGRect: @retroactive VectorArithmetic { 52 | public static func - (lhs: Self, rhs: Self) -> Self { 53 | .init( 54 | x: lhs.origin.x - rhs.origin.x, 55 | y: lhs.origin.y - rhs.origin.y, 56 | width: lhs.size.width - rhs.size.width, 57 | height: lhs.size.height - rhs.size.height 58 | ) 59 | } 60 | 61 | public static func + (lhs: Self, rhs: Self) -> Self { 62 | .init( 63 | x: lhs.origin.x + rhs.origin.x, 64 | y: lhs.origin.y + rhs.origin.y, 65 | width: lhs.size.width + rhs.size.width, 66 | height: lhs.size.height + rhs.size.height 67 | ) 68 | } 69 | 70 | public mutating func scale(by rhs: Double) { 71 | origin.x *= CGFloat(rhs) 72 | origin.y *= CGFloat(rhs) 73 | size.width *= CGFloat(rhs) 74 | size.height *= CGFloat(rhs) 75 | } 76 | 77 | public var magnitudeSquared: Double { 78 | origin.x * origin.x + origin.y * origin.y + size.width * size.width + size.height * size.height 79 | } 80 | } 81 | 82 | protocol ApproximatelyEqual { 83 | static func approximatelyEqual(_ lhs: Self, _ rhs: Self) -> Bool 84 | } 85 | 86 | private let threshold: Double = 0.01 87 | 88 | extension CGRect: ApproximatelyEqual { 89 | static func approximatelyEqual(_ lhs: CGRect, _ rhs: CGRect) -> Bool { 90 | abs(lhs.origin.x - rhs.origin.x) < threshold 91 | && abs(lhs.origin.y - rhs.origin.y) < threshold 92 | && abs(lhs.size.width - rhs.size.width) < threshold 93 | && abs(lhs.size.height - rhs.size.height) < threshold 94 | } 95 | } 96 | 97 | extension CGFloat: ApproximatelyEqual { 98 | static func approximatelyEqual(_ lhs: CGFloat, _ rhs: CGFloat) -> Bool { 99 | abs(lhs - rhs) < threshold 100 | } 101 | } 102 | 103 | extension Float: ApproximatelyEqual { 104 | static func approximatelyEqual(_ lhs: Float, _ rhs: Float) -> Bool { 105 | abs(lhs - rhs) < Float(threshold) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Sources/GlyphixTextFx/ArrayContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2024/11/17. 3 | // Copyright (c) 2024 ktiays. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | final class ArrayContainer: Sequence, ExpressibleByArrayLiteral { 9 | private var array: [T] 10 | 11 | var isEmpty: Bool { 12 | array.isEmpty 13 | } 14 | 15 | init(_ array: [T] = []) { 16 | self.array = array 17 | } 18 | 19 | init(arrayLiteral elements: T...) { 20 | array = elements 21 | } 22 | 23 | @inlinable 24 | func append(_ newElement: T) { 25 | array.append(newElement) 26 | } 27 | 28 | @inlinable 29 | func remove(at index: Int) -> T { 30 | array.remove(at: index) 31 | } 32 | 33 | @inlinable 34 | func removeAll(keepingCapacity: Bool = false) { 35 | array.removeAll(keepingCapacity: keepingCapacity) 36 | } 37 | 38 | @inlinable 39 | func first(where predicate: (T) throws -> Bool) rethrows -> T? { 40 | try array.first(where: predicate) 41 | } 42 | 43 | @inlinable 44 | func removeAll(where shouldBeRemoved: (T) throws -> Bool) rethrows { 45 | try array.removeAll(where: shouldBeRemoved) 46 | } 47 | 48 | func makeIterator() -> IndexingIterator<[T]> { 49 | array.makeIterator() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/GlyphixTextFx/Filter/CAFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2024/11/17. 3 | // Copyright (c) 2024 ktiays. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | func makeCAFilter(with name: String) -> NSObject? { 9 | let className = ["Filter", "CA"].reversed().joined() 10 | guard let caFilterClass = NSClassFromString(className) as? NSObject.Type else { return nil } 11 | 12 | let methodType = (@convention(c) (AnyClass, Selector, String) -> NSObject).self 13 | let selectorName = ["Name:", "With", "filter"].reversed().joined() 14 | let selector = NSSelectorFromString(selectorName) 15 | 16 | guard caFilterClass.responds(to: selector) else { return nil } 17 | 18 | let implementation = caFilterClass.method(for: selector) 19 | let method = unsafeBitCast(implementation, to: methodType) 20 | 21 | return method(caFilterClass, selector, name) 22 | } 23 | -------------------------------------------------------------------------------- /Sources/GlyphixTextFx/Filter/ColorAddFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2024/11/17. 3 | // Copyright (c) 2024 ktiays. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import QuartzCore 8 | 9 | struct ColorAddFilter { 10 | 11 | static let inputColorKeyPath = "filters.colorAdd.inputColor" 12 | 13 | let effect: NSObject 14 | 15 | var inputColor: CGColor? { 16 | get { 17 | if let color = effect.value(forKey: "inputColor") { 18 | return (color as! CGColor) 19 | } 20 | return nil 21 | } 22 | set { effect.setValue(newValue, forKey: "inputColor") } 23 | } 24 | 25 | init?() { 26 | guard let effect = makeCAFilter(with: "colorAdd") else { 27 | return nil 28 | } 29 | self.effect = effect 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/GlyphixTextFx/Filter/GaussianBlurFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2024/11/12. 3 | // Copyright (c) 2024 ktiays. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import QuartzCore 8 | 9 | struct GaussianBlurFilter { 10 | 11 | static let inputRadiusKeyPath = "filters.gaussianBlur.inputRadius" 12 | 13 | let effect: NSObject 14 | 15 | var inputRadius: Double { 16 | get { effect.value(forKey: "inputRadius") as? Double ?? 0 } 17 | set { effect.setValue(newValue, forKey: "inputRadius") } 18 | } 19 | 20 | init?() { 21 | guard let effect = makeCAFilter(with: "gaussianBlur") else { 22 | return nil 23 | } 24 | self.effect = effect 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/GlyphixTextFx/GlyphixText/GlyphixText+Animation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by LiYanan2004 on 2025/3/14. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | extension View { 9 | 10 | /// Sets whether label should disable animations. 11 | public func glyphixTextAnimationDisabled(_ disabled: Bool = true) -> some View { 12 | transformEnvironment(\.disablesGlyphixTextAnimations) { disablesAnimations in 13 | disablesAnimations = disabled 14 | } 15 | } 16 | } 17 | 18 | struct GlyphixTextAnimationsDisabled: EnvironmentKey { 19 | static var defaultValue: Bool = false 20 | } 21 | 22 | extension EnvironmentValues { 23 | var disablesGlyphixTextAnimations: Bool { 24 | get { self[GlyphixTextAnimationsDisabled.self] } 25 | set { self[GlyphixTextAnimationsDisabled.self] = newValue } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/GlyphixTextFx/GlyphixText/GlyphixText+Blur.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by LiYanan2004 on 2025/3/10. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | extension View { 9 | /// Sets whether label should disable blur effect. 10 | public func glyphixTextBlurEffectDisabled(_ disabled: Bool = true) -> some View { 11 | transformEnvironment(\.blursDuringTransition) { blurEffectEnabled in 12 | blurEffectEnabled = !disabled 13 | } 14 | } 15 | } 16 | 17 | struct GlyphixTextBlursDuringTransition: EnvironmentKey { 18 | static var defaultValue: Bool = true 19 | } 20 | 21 | extension EnvironmentValues { 22 | var blursDuringTransition: Bool { 23 | get { self[GlyphixTextBlursDuringTransition.self] } 24 | set { self[GlyphixTextBlursDuringTransition.self] = newValue } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/GlyphixTextFx/GlyphixText/GlyphixText+Font.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by LiYanan2004 on 2025/3/10. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | import GlyphixTypesetter 8 | 9 | extension View { 10 | /// Sets the font of the label. 11 | public func glyphixTextFont(_ font: PlatformFont?) -> some View { 12 | transformEnvironment(\.glyphixTextFont) { glyphixLabelFont in 13 | glyphixLabelFont = font 14 | } 15 | } 16 | } 17 | 18 | struct GlyphixTextFont: EnvironmentKey { 19 | static var defaultValue: PlatformFont? = nil 20 | } 21 | 22 | extension EnvironmentValues { 23 | var glyphixTextFont: PlatformFont? { 24 | get { self[GlyphixTextFont.self] } 25 | set { self[GlyphixTextFont.self] = newValue } 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /Sources/GlyphixTextFx/GlyphixText/GlyphixText+TextColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by LiYanan2004 on 2025/3/10. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import SwiftUI 7 | 8 | extension View { 9 | /// Sets the color of the label. 10 | public func glyphixTextColor(_ color: PlatformColor?) -> some View { 11 | transformEnvironment(\.glyphixTextColor) { glyphixLabelColor in 12 | glyphixLabelColor = color 13 | } 14 | } 15 | } 16 | 17 | struct GlyphixTextColor: EnvironmentKey { 18 | static var defaultValue: PlatformColor? = nil 19 | } 20 | 21 | extension EnvironmentValues { 22 | var glyphixTextColor: PlatformColor? { 23 | get { self[GlyphixTextColor.self] } 24 | set { self[GlyphixTextColor.self] = newValue } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/GlyphixTextFx/GlyphixText/GlyphixText.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2025/2/24. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import GlyphixTypesetter 7 | import SwiftUI 8 | 9 | /// A view that displays one or more lines of read-only text with built-in glyph-level animations. 10 | @MainActor 11 | public struct GlyphixText { 12 | 13 | public var text: String 14 | public var countsDown: Bool 15 | 16 | private var font: PlatformFont? 17 | private var textColor: PlatformColor? 18 | private var lineBreakMode: NSLineBreakMode? 19 | private var alignment: TextAlignment? 20 | private var lineLimit: Int? 21 | private var isBlurEffectEnabled: Bool? 22 | private var isAnimationDisabled: Bool? 23 | 24 | @Environment(\.glyphixTextFont) private var environmentFont 25 | @Environment(\.glyphixTextColor) private var environmentTextColor 26 | @Environment(\.truncationMode) private var environmentTruncationMode 27 | @Environment(\.multilineTextAlignment) private var environmentTextAlignment 28 | @Environment(\.lineLimit) private var environmentLineLimit 29 | @Environment(\.blursDuringTransition) private var environmentBlursDuringTransition 30 | @Environment(\.disablesGlyphixTextAnimations) private var environmentDisablesAnimations 31 | 32 | /// Creates a text view that displays a stored string without localization. 33 | public init(_ text: S, countsDown: Bool = false) where S: StringProtocol { 34 | self.init(text: .init(text), countsDown: countsDown) 35 | } 36 | 37 | /// Creates a text view that displays a localized string resource. 38 | @available(iOS 16.0, macOS 13.0, *) 39 | public init(_ resource: LocalizedStringResource, countsDown: Bool = false) { 40 | self.init(text: .init(localized: resource), countsDown: true) 41 | } 42 | 43 | private init(text: String, countsDown: Bool) { 44 | self.text = text 45 | self.countsDown = countsDown 46 | } 47 | 48 | private func updateView(_ view: GlyphixTextLabel) { 49 | view.text = text 50 | view.font = font ?? (environmentFont ?? .glyphixDefaultFont) 51 | view.textColor = textColor ?? (environmentTextColor ?? .glyphixDefaultColor) 52 | view.countsDown = countsDown 53 | view.numberOfLines = lineLimit ?? (environmentLineLimit ?? 0) 54 | view.textAlignment = 55 | if let alignment { 56 | alignment 57 | } else { 58 | textAlignment(from: environmentTextAlignment) 59 | } 60 | view.lineBreakMode = 61 | if let lineBreakMode { 62 | lineBreakMode 63 | } else { 64 | lineBreakMode(from: environmentTruncationMode) 65 | } 66 | view.disablesAnimations = isAnimationDisabled ?? environmentDisablesAnimations 67 | view.isBlurEffectEnabled = isBlurEffectEnabled ?? environmentBlursDuringTransition 68 | } 69 | 70 | private func textAlignment(from alignment: SwiftUI.TextAlignment) -> TextAlignment { 71 | switch alignment { 72 | case .leading: 73 | .leading 74 | case .center: 75 | .center 76 | case .trailing: 77 | .trailing 78 | } 79 | } 80 | 81 | private func lineBreakMode(from truncationMode: Text.TruncationMode) -> NSLineBreakMode { 82 | switch truncationMode { 83 | case .head: 84 | .byTruncatingHead 85 | case .middle: 86 | .byTruncatingMiddle 87 | case .tail: 88 | .byTruncatingTail 89 | @unknown default: 90 | .byWordWrapping 91 | } 92 | } 93 | 94 | @available(iOS 16.0, macOS 13.0, *) 95 | private func sizeThatFits(_ proposal: ProposedViewSize, view: GlyphixTextLabel) -> CGSize { 96 | switch proposal { 97 | case .zero: 98 | return .zero 99 | case .infinity, .unspecified: 100 | return view.sizeThatFits( 101 | .init( 102 | width: CGFloat.greatestFiniteMagnitude, 103 | height: CGFloat.greatestFiniteMagnitude 104 | ) 105 | ) 106 | default: 107 | var size = proposal.replacingUnspecifiedDimensions(by: .greatestFiniteMagnitude) 108 | if size.width == 0 { 109 | size.width = CGFloat.greatestFiniteMagnitude 110 | } 111 | if size.height == 0 { 112 | size.height = CGFloat.greatestFiniteMagnitude 113 | } 114 | let result = view.sizeThatFits(size) 115 | return result 116 | } 117 | } 118 | } 119 | 120 | #if os(iOS) 121 | import UIKit 122 | 123 | extension GlyphixText: UIViewRepresentable { 124 | 125 | public func makeUIView(context: Context) -> GlyphixTextLabel { 126 | .init() 127 | } 128 | 129 | public func updateUIView(_ uiView: GlyphixTextLabel, context: Context) { 130 | updateView(uiView) 131 | } 132 | 133 | @available(iOS 16.0, *) 134 | public func sizeThatFits(_ proposal: ProposedViewSize, uiView: GlyphixTextLabel, context: Context) -> CGSize? { 135 | sizeThatFits(proposal, view: uiView) 136 | } 137 | } 138 | #elseif os(macOS) 139 | import AppKit 140 | 141 | extension GlyphixText: NSViewRepresentable { 142 | 143 | public func makeNSView(context: Context) -> GlyphixTextLabel { 144 | .init() 145 | } 146 | 147 | public func updateNSView(_ nsView: GlyphixTextLabel, context: Context) { 148 | updateView(nsView) 149 | } 150 | 151 | @available(macOS 13.0, *) 152 | public func sizeThatFits(_ proposal: ProposedViewSize, nsView: GlyphixTextLabel, context: Context) -> CGSize? { 153 | sizeThatFits(proposal, view: nsView) 154 | } 155 | } 156 | #endif 157 | 158 | extension GlyphixText { 159 | 160 | /// Sets the font for text in the view. 161 | @available(*, deprecated, renamed: "glyphixTextFont") 162 | public func font(_ font: PlatformFont) -> Self { 163 | glyphixTextFont(font) 164 | } 165 | 166 | /// Sets the font for text in the view. 167 | public func glyphixTextFont(_ font: PlatformFont) -> Self { 168 | var view = self 169 | view.font = font 170 | return view 171 | } 172 | 173 | /// Sets the technique for aligning the text. 174 | @available(*, deprecated, message: "Use `View.multilineTextAlignment(_:)` instead.") 175 | public func textAlignment(_ alignment: TextAlignment) -> Self { 176 | var view = self 177 | view.alignment = alignment 178 | return view 179 | } 180 | 181 | /// Sets the alignment of a text view that contains multiple lines of text. 182 | public func multilineTextAlignment(_ alignment: SwiftUI.TextAlignment) -> Self { 183 | var view = self 184 | view.alignment = textAlignment(from: alignment) 185 | return view 186 | } 187 | 188 | /// Sets the maximum number of lines that text can occupy in this view. 189 | public func lineLimit(_ limit: Int?) -> Self { 190 | var view = self 191 | view.lineLimit = limit 192 | return view 193 | } 194 | 195 | /// Sets the color of the text displayed by this view. 196 | @available(*, deprecated, renamed: "glyphixTextColor") 197 | public func textColor(_ color: PlatformColor) -> Self { 198 | glyphixTextColor(color) 199 | } 200 | 201 | /// Sets the color of the text displayed by this view. 202 | public func glyphixTextColor(_ color: PlatformColor) -> Self { 203 | var view = self 204 | view.textColor = color 205 | return view 206 | } 207 | 208 | /// Sets the direction of the text animation. 209 | @available(*, deprecated, message: "Use `GlyphixText(_:countsDown:)` instead.") 210 | public func countsDown(_ countsDown: Bool = false) -> Self { 211 | var view = self 212 | view.countsDown = countsDown 213 | return view 214 | } 215 | 216 | /// Sets the technique for wrapping and truncating the label's text. 217 | @available(*, deprecated, renamed: "truncationMode") 218 | public func lineBreakMode(_ mode: NSLineBreakMode) -> Self { 219 | var view = self 220 | view.lineBreakMode = mode 221 | return view 222 | } 223 | 224 | /// Sets the truncation mode for lines of text that are too long to fit in the available space. 225 | public func truncationMode(_ mode: Text.TruncationMode) -> Self { 226 | var view = self 227 | view.lineBreakMode = lineBreakMode(from: mode) 228 | return view 229 | } 230 | 231 | /// Sets whether label should disable animations. 232 | @available(*, deprecated, renamed: "glyphixTextAnimationDisabled") 233 | public func disablesAnimations(_ disables: Bool = true) -> Self { 234 | glyphixTextAnimationDisabled(disables) 235 | } 236 | 237 | /// Sets whether label should disable animations. 238 | public func glyphixTextAnimationDisabled(_ disables: Bool = true) -> Self { 239 | var view = self 240 | view.isAnimationDisabled = disables 241 | return view 242 | } 243 | 244 | /// Sets whether label should disable blur effect. 245 | @available(*, deprecated, renamed: "glyphixTextBlurEffectDisabled") 246 | public func disablesBlurEffect(_ disables: Bool = true) -> Self { 247 | glyphixTextBlurEffectDisabled(disables) 248 | } 249 | 250 | /// Sets whether label should disable blur effect. 251 | public func glyphixTextBlurEffectDisabled(_ disables: Bool = true) -> Self { 252 | var view = self 253 | view.isBlurEffectEnabled = disables 254 | return view 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /Sources/GlyphixTextFx/GlyphixTextLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2024/11/16. 3 | // Copyright (c) 2024 ktiays. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import GlyphixHook 8 | import GlyphixTypesetter 9 | import With 10 | 11 | public typealias TextAlignment = GlyphixTypesetter.TextAlignment 12 | 13 | /// A view with smooth per-character animation, like `UILabel`. 14 | @MainActor 15 | open class GlyphixTextLabel: PlatformView { 16 | 17 | /// The text that the label displays. 18 | /// 19 | /// This property is animatable. 20 | public var text: String? { 21 | set { 22 | textLayer.text = newValue 23 | _invalidateIntrinsicContentSize() 24 | } 25 | get { textLayer.text } 26 | } 27 | 28 | /// The font of the text. 29 | /// 30 | /// The default value for this property is the system font at a size of 17 points. 31 | public var font: PlatformFont? { 32 | set { 33 | textLayer.font = newValue 34 | _invalidateIntrinsicContentSize() 35 | } 36 | get { textLayer.font } 37 | } 38 | 39 | /// The color of the text. 40 | /// 41 | /// The default value for this property is the system's label color. 42 | /// This property is animatable. 43 | public var textColor: PlatformColor { 44 | set { textLayer.textColor = newValue } 45 | get { textLayer.textColor } 46 | } 47 | 48 | /// A Boolean value that indicates the direction of the text animation. 49 | /// 50 | /// Set this property to `true` to animate the text moving downward, or `false` to animate it moving upward. 51 | /// This direction applies to the visual motion of the text content during the transition. 52 | public var countsDown: Bool { 53 | set { textLayer.countsDown = newValue } 54 | get { textLayer.countsDown } 55 | } 56 | 57 | /// The technique for aligning the text. 58 | /// 59 | /// The default value for this property is `left`. 60 | public var textAlignment: TextAlignment { 61 | set { textLayer.alignment = newValue } 62 | get { textLayer.alignment } 63 | } 64 | 65 | /// The technique for wrapping and truncating the label's text. 66 | public var lineBreakMode: NSLineBreakMode { 67 | set { textLayer.lineBreakMode = newValue } 68 | get { textLayer.lineBreakMode } 69 | } 70 | 71 | /// The maximum number of lines for rendering text. 72 | /// 73 | /// This property controls the maximum number of lines to use in order to fit the label's text into 74 | /// its bounding rectangle. The default value for this property is `1`. To remove any maximum limit, 75 | /// and use as many lines as needed, set the value of this property to `0`. 76 | public var numberOfLines: Int { 77 | set { 78 | textLayer.numberOfLines = newValue 79 | _invalidateIntrinsicContentSize() 80 | } 81 | get { textLayer.numberOfLines } 82 | } 83 | 84 | /// A Boolean value that indicates whether views should disable animations. 85 | public var disablesAnimations: Bool { 86 | set { textLayer.disablesAnimations = newValue } 87 | get { textLayer.disablesAnimations } 88 | } 89 | 90 | /// A Boolean value that indicates whether blur effect is enabled when transitioning text. 91 | /// 92 | /// Blur is a visual effect that incurs significant performance overhead. 93 | /// When dealing with lengthy text, it is recommended to disable the blur effect to 94 | ///achieve better performance and improve user experience. 95 | /// 96 | /// The default value for this property is `true`. 97 | public var isBlurEffectEnabled: Bool { 98 | set { textLayer.isBlurEffectEnabled = newValue } 99 | get { textLayer.isBlurEffectEnabled } 100 | } 101 | 102 | /// The preferred maximum width, in points, for a multiline label. 103 | public var preferredMaxLayoutWidth: CGFloat = 0 { 104 | didSet { 105 | _invalidateIntrinsicContentSize() 106 | #if os(iOS) 107 | let needsDoubleUpdateConstraintsPass = self.gtf_invokeSuper(forSelectorReturnsBoolean: "_needsDoubleUpdateConstraintsPass") 108 | self.gtf_invokeSuper( 109 | for: "_needsDoubleUpdateConstraintsPassMayHaveChangedFrom:", 110 | withBooleanArgReturnsBoolean: needsDoubleUpdateConstraintsPass 111 | ) 112 | #endif 113 | } 114 | } 115 | private var _preferredMaxLayoutWidth: CGFloat = 0 116 | private var needsDoubleUpdateConstraintsPass: Bool { 117 | numberOfLines != 1 118 | } 119 | 120 | /// A Boolean value that specifies whether to enable font smoothing. 121 | /// 122 | /// The default value for this property is `false`. 123 | public var isSmoothRenderingEnabled: Bool { 124 | set { textLayer.isSmoothRenderingEnabled = newValue } 125 | get { textLayer.isSmoothRenderingEnabled } 126 | } 127 | 128 | private var textLayer: GlyphixTextLayer { 129 | layer as! GlyphixTextLayer 130 | } 131 | 132 | override public init(frame: CGRect) { 133 | super.init(frame: frame) 134 | 135 | #if os(iOS) 136 | configureAutoLayoutMethods() 137 | #elseif os(macOS) 138 | wantsLayer = true 139 | #endif 140 | } 141 | 142 | public required init?(coder: NSCoder) { 143 | super.init(coder: coder) 144 | 145 | #if os(iOS) 146 | configureAutoLayoutMethods() 147 | #elseif os(macOS) 148 | wantsLayer = true 149 | #endif 150 | } 151 | 152 | override public var intrinsicContentSize: CGSize { 153 | let layoutWidth = min(_preferredMaxLayoutWidth, preferredMaxLayoutWidth == 0 ? .greatestFiniteMagnitude : preferredMaxLayoutWidth) 154 | let frame = frame( 155 | forAlignmentRect: .init( 156 | x: 0, 157 | y: 0, 158 | width: layoutWidth == 0 ? CGFloat.greatestFiniteMagnitude : layoutWidth, 159 | height: .greatestFiniteMagnitude 160 | ) 161 | ) 162 | return ceil(textLayer.size(fitting: frame.size)) 163 | } 164 | 165 | private func _invalidateIntrinsicContentSize() { 166 | _preferredMaxLayoutWidth = .greatestFiniteMagnitude 167 | invalidateIntrinsicContentSize() 168 | } 169 | 170 | #if os(iOS) 171 | override public class var layerClass: AnyClass { 172 | GlyphixTextLayer.self 173 | } 174 | 175 | override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 176 | textLayer.effectiveAppearanceDidChange(traitCollection) 177 | } 178 | 179 | override open func willMove(toSuperview newSuperview: UIView?) { 180 | super.willMove(toSuperview: newSuperview) 181 | 182 | if let newSuperview { 183 | textLayer.effectiveAppearanceDidChange(newSuperview.traitCollection) 184 | } 185 | } 186 | 187 | open override func willMove(toWindow newWindow: UIWindow?) { 188 | super.willMove(toWindow: newWindow) 189 | if newWindow != nil { 190 | textLayer.displaySyncObserver = try? .init() 191 | } else { 192 | try? textLayer.displaySyncObserver?.invalidate() 193 | textLayer.displaySyncObserver = nil 194 | } 195 | } 196 | 197 | override open func sizeThatFits(_ size: CGSize) -> CGSize { 198 | ceil(textLayer.size(fitting: size)) 199 | } 200 | 201 | private func configureAutoLayoutMethods() { 202 | with("_prepareForFirstIntrinsicContentSizeCalculation") { sel in 203 | self.gtf_addInstanceMethod(sel) { this in 204 | guard let textLabel = this as? GlyphixTextLabel else { 205 | return 206 | } 207 | textLabel._preferredMaxLayoutWidth = .greatestFiniteMagnitude 208 | textLabel.gtf_invokeSuper(for: sel) 209 | } 210 | } 211 | with("_prepareForSecondIntrinsicContentSizeCalculationWithLayoutEngineBounds:") { sel in 212 | self.gtf_addInstanceMethod(sel) { this, bounds in 213 | guard let textLabel = this as? GlyphixTextLabel else { 214 | return 215 | } 216 | let frame = textLabel.alignmentRect(forFrame: bounds) 217 | textLabel._preferredMaxLayoutWidth = frame.width 218 | textLabel.gtf_invokeSuper(for: sel, with: bounds) 219 | } 220 | } 221 | with("_needsDoubleUpdateConstraintsPass") { sel in 222 | self.gtf_addInstanceMethod(sel) { this in 223 | guard let textLabel = this as? GlyphixTextLabel else { 224 | return false 225 | } 226 | 227 | return textLabel.needsDoubleUpdateConstraintsPass 228 | } 229 | } 230 | with("_preferredMaxLayoutWidth") { sel in 231 | self.gtf_addInstanceMethod(sel) { this in 232 | guard let textLabel = this as? GlyphixTextLabel else { 233 | return 0 234 | } 235 | 236 | return textLabel._preferredMaxLayoutWidth 237 | } 238 | } 239 | } 240 | #elseif os(macOS) 241 | override public var isFlipped: Bool { true } 242 | 243 | private var finishedFirstConstraintsPass: Bool = false 244 | 245 | override public func makeBackingLayer() -> CALayer { 246 | GlyphixTextLayer() 247 | } 248 | 249 | override public func viewDidChangeEffectiveAppearance() { 250 | super.viewDidChangeEffectiveAppearance() 251 | 252 | textLayer.effectiveAppearanceDidChange(effectiveAppearance) 253 | } 254 | 255 | override open func viewWillMove(toSuperview newSuperview: NSView?) { 256 | super.viewWillMove(toSuperview: newSuperview) 257 | 258 | if let newSuperview { 259 | textLayer.effectiveAppearanceDidChange(newSuperview.effectiveAppearance) 260 | } 261 | } 262 | 263 | override open func viewWillMove(toWindow newWindow: NSWindow?) { 264 | super.viewWillMove(toWindow: newWindow) 265 | if let newWindow, let screen = newWindow.screen { 266 | textLayer.displaySyncObserver = try? .init(screen: screen) 267 | } else { 268 | try? textLayer.displaySyncObserver?.invalidate() 269 | textLayer.displaySyncObserver = nil 270 | } 271 | } 272 | 273 | override open func updateConstraints() { 274 | defer { super.updateConstraints() } 275 | if !needsDoubleUpdateConstraintsPass { 276 | return 277 | } 278 | if !finishedFirstConstraintsPass { 279 | DispatchQueue.main.async { [weak self] in 280 | self?.needsUpdateConstraints = true 281 | } 282 | finishedFirstConstraintsPass = true 283 | _preferredMaxLayoutWidth = .greatestFiniteMagnitude 284 | return 285 | } 286 | 287 | guard let engine = self.perform("_layoutEngine").takeUnretainedValue() as? NSObject else { 288 | return 289 | } 290 | engine.perform("optimize") 291 | guard let imp = self.gtf_getImplementation(for: "nsis_frame") else { 292 | return 293 | } 294 | let fn = unsafeBitCast(imp, to: (@convention(c) (AnyObject, Selector?) -> CGRect).self) 295 | let frame = fn(self, nil) 296 | _preferredMaxLayoutWidth = frame.width 297 | invalidateIntrinsicContentSize() 298 | finishedFirstConstraintsPass = false 299 | } 300 | 301 | /// Asks the label to calculate and return the size that best fits the specified size. 302 | open func sizeThatFits(_ size: CGSize) -> CGSize { 303 | ceil(textLayer.size(fitting: size)) 304 | } 305 | #endif 306 | } 307 | -------------------------------------------------------------------------------- /Sources/GlyphixTextFx/GlyphixTextLayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2024/11/16. 3 | // Copyright (c) 2024 ktiays. All rights reserved. 4 | // 5 | 6 | import Choreographer 7 | import GlyphixTypesetter 8 | import Respring 9 | 10 | #if os(iOS) 11 | import UIKit 12 | #else 13 | import AppKit 14 | #endif 15 | 16 | open class GlyphixTextLayer: CALayer { 17 | 18 | /// The text that the label displays. 19 | public var text: String? { 20 | didSet { 21 | if text == attributedText?.string { 22 | return 23 | } 24 | setNeedsUpdateTextLayout() 25 | } 26 | } 27 | private var attributedText: NSAttributedString? 28 | 29 | public var font: PlatformFont? { 30 | didSet { 31 | if font == oldValue { 32 | return 33 | } 34 | setNeedsUpdateTextLayout() 35 | } 36 | } 37 | private let defaultFont: PlatformFont = .glyphixDefaultFont 38 | 39 | private var effectiveFont: PlatformFont { 40 | font ?? defaultFont 41 | } 42 | 43 | /// The color of the text. 44 | public var textColor: PlatformColor = .glyphixDefaultColor { 45 | didSet { 46 | colorAnimation.target = textColor.resolvedRgbColor(with: effectiveAppearance) 47 | if disablesAnimations { 48 | colorAnimation.velocity = .zero 49 | colorAnimation.value = colorAnimation.target 50 | for (layer, _) in layerStates { 51 | updateLayerColor(layer) 52 | } 53 | } 54 | } 55 | } 56 | 57 | private lazy var colorAnimation: AnimationState = .init( 58 | value: textColor.resolvedRgbColor(with: effectiveAppearance), 59 | velocity: .zero, 60 | target: textColor.resolvedRgbColor(with: effectiveAppearance) 61 | ) 62 | 63 | /// A Boolean value that indicates the direction of the text animation. 64 | public var countsDown: Bool = false 65 | 66 | /// The technique for aligning the text. 67 | /// 68 | /// The default value for this property is `left`. 69 | public var alignment: TextAlignment = .leading { 70 | didSet { 71 | if oldValue == alignment { 72 | return 73 | } 74 | setNeedsUpdateTextLayout() 75 | } 76 | } 77 | 78 | /// The technique for wrapping and truncating the layer's text. 79 | public var lineBreakMode: NSLineBreakMode = .byTruncatingTail { 80 | didSet { 81 | if oldValue == lineBreakMode { 82 | return 83 | } 84 | setNeedsUpdateTextLayout() 85 | } 86 | } 87 | 88 | private var needsLastLineTruncation: Bool { 89 | switch lineBreakMode { 90 | case .byTruncatingTail, .byTruncatingHead, .byTruncatingMiddle, .byClipping: 91 | true 92 | default: 93 | false 94 | } 95 | } 96 | 97 | /// The maximum number of lines for rendering text. 98 | public var numberOfLines: Int = 1 { 99 | didSet { 100 | if oldValue == numberOfLines { 101 | return 102 | } 103 | setNeedsUpdateTextLayout() 104 | } 105 | } 106 | 107 | /// A Boolean value that specifies whether to enable font smoothing. 108 | public var isSmoothRenderingEnabled: Bool = false { 109 | didSet { 110 | if isSmoothRenderingEnabled == oldValue { 111 | return 112 | } 113 | layerStates.forEach { 114 | $0.0.setNeedsDisplay() 115 | } 116 | } 117 | } 118 | 119 | /// A Boolean value that indicates whether views should disable animations. 120 | public var disablesAnimations: Bool = false { 121 | didSet { 122 | if oldValue == disablesAnimations || !disablesAnimations { 123 | return 124 | } 125 | updateLayersToTarget() 126 | } 127 | } 128 | 129 | /// A Boolean value that indicates whether blur effect is enabled when transitioning text. 130 | public var isBlurEffectEnabled: Bool = true { 131 | didSet { 132 | if oldValue == isBlurEffectEnabled || isBlurEffectEnabled { 133 | return 134 | } 135 | 136 | for (_, state) in layerStates { 137 | state.blurRadiusAnimation = nil 138 | state.blurRadius = 0 139 | } 140 | } 141 | } 142 | 143 | private var containerBounds: CGRect = .zero 144 | 145 | private var textLayout: TextLayout? 146 | private var layerStates: [CALayer: LayerState] = [:] 147 | private var glyphStates: [String: ArrayContainer] = [:] 148 | private var isLayoutDirty: Bool = false 149 | 150 | private let smoothSpring: Spring = .smooth 151 | private let snappySpring: Spring = .init(duration: 0.3) 152 | private let phoneSpring: Spring = .smooth(duration: 0.42) 153 | private let bouncySpring: Spring = .init(response: 0.42, dampingRatio: 0.8) 154 | private var effectiveAppearance: Appearance = .initialValue 155 | 156 | /// The display sync observer that drives animations of this layer. 157 | /// 158 | /// Clients must invalidate the observer when tearing down, and the 159 | /// layer will only set `frameUpdateHandler` of the observer. 160 | @MainActor 161 | var displaySyncObserver: VSyncObserver? { 162 | didSet { 163 | configureDisplaySyncObserver() 164 | } 165 | } 166 | private var lastFrameTimestamp: CFTimeInterval? 167 | 168 | @MainActor 169 | private func configureDisplaySyncObserver() { 170 | guard let displaySyncObserver else { 171 | return 172 | } 173 | lastFrameTimestamp = nil 174 | displaySyncObserver.frameUpdateHandler = { [weak self] context in 175 | CATransaction.begin() 176 | CATransaction.setDisableActions(true) 177 | self?.animateTransition(with: context) 178 | CATransaction.commit() 179 | } 180 | } 181 | 182 | override public func action(forKey key: String) -> (any CAAction)? { 183 | NSNull() 184 | } 185 | 186 | override public func layoutSublayers() { 187 | super.layoutSublayers() 188 | 189 | if containerBounds != bounds { 190 | containerBounds = bounds 191 | setNeedsUpdateTextLayout() 192 | return 193 | } 194 | 195 | if isLayoutDirty { 196 | updateTextLayout() 197 | } 198 | 199 | for (_, state) in layerStates { 200 | updateFrame(with: state) 201 | } 202 | } 203 | 204 | func effectiveAppearanceDidChange(_ appearance: Appearance) { 205 | effectiveAppearance = appearance 206 | let color = textColor 207 | textColor = color 208 | } 209 | 210 | public func size(fitting constrainedSize: CGSize) -> CGSize { 211 | guard let textLayout else { 212 | return .zero 213 | } 214 | 215 | return textLayout.size(fitting: constrainedSize) 216 | } 217 | } 218 | 219 | extension GlyphixTextLayer { 220 | 221 | final class LayerState { 222 | 223 | protocol Delegate: AnyObject { 224 | 225 | var isBlurEffectEnabled: Bool { get } 226 | 227 | func updateFrame(with state: LayerState) 228 | } 229 | 230 | var frame: CGRect = .zero { 231 | didSet { 232 | if frame == presentationFrame { 233 | return 234 | } 235 | var animation: AnimationState = frameAnimation ?? .init(value: presentationFrame, velocity: .zero, target: frame) 236 | animation.target = frame 237 | frameAnimation = animation 238 | } 239 | } 240 | 241 | var presentationFrame: CGRect = .zero { 242 | didSet { 243 | delegate?.updateFrame(with: self) 244 | } 245 | } 246 | 247 | var frameAnimation: AnimationState? 248 | 249 | var scale: CGFloat = 1 250 | var scaleAnimation: AnimationState? 251 | 252 | var offset: CGFloat = 0 253 | var offsetAnimation: AnimationState? 254 | 255 | var opacity: Float = 1 { 256 | didSet { layer.opacity = opacity } 257 | } 258 | var opacityAnimation: AnimationState? 259 | 260 | var blurRadius: CGFloat = 0 { 261 | didSet { 262 | // Avoid fractional blur radius for performance reasons. 263 | let targetRadius = round(blurRadius) 264 | if targetRadius == round(oldValue) { 265 | // No need to update the layer if the value is the same. 266 | return 267 | } 268 | layer.setValue(targetRadius, forKeyPath: GaussianBlurFilter.inputRadiusKeyPath) 269 | } 270 | } 271 | var blurRadiusAnimation: AnimationState? 272 | 273 | var delay: TimeInterval = 0 274 | var invalid: Bool = false 275 | 276 | var isAnimating: Bool { 277 | frameAnimation != nil 278 | || scaleAnimation != nil 279 | || offsetAnimation != nil 280 | || opacityAnimation != nil 281 | || blurRadiusAnimation != nil 282 | } 283 | 284 | var isVisible: Bool { 285 | if let opacityAnimation { 286 | return opacityAnimation.value >= 0.01 287 | } 288 | return opacity >= 0.01 289 | } 290 | 291 | let layer: CALayer 292 | 293 | var key: String? 294 | var font: CTFont? 295 | var glyph: CGGlyph? 296 | var boundingRect: CGRect = .zero 297 | var descent: CGFloat = 0 298 | 299 | weak var delegate: (any Delegate)? 300 | 301 | init(layer: CALayer) { 302 | self.layer = layer 303 | } 304 | 305 | func updateTransform() { 306 | let transform = CATransform3DConcat( 307 | CATransform3DMakeScale(scale, scale, 1), 308 | CATransform3DMakeTranslation(0, offset * frame.height / 3, 0) 309 | ) 310 | layer.transform = transform 311 | } 312 | 313 | enum AnimationType { 314 | case appear 315 | case disappear 316 | } 317 | 318 | private static let smallestScale: CGFloat = 0.4 319 | 320 | private var appearBlurRadius: CGFloat { 321 | log(frame.height) / log(3) 322 | } 323 | private var disappearBlurRadius: CGFloat { 324 | log(frame.height) 325 | } 326 | 327 | func configureAnimation(with type: AnimationType, countsDown: Bool = false) { 328 | switch type { 329 | case .appear: 330 | scaleAnimation = .init(value: Self.smallestScale, velocity: .zero, target: 1) 331 | scale = Self.smallestScale 332 | let offset: CGFloat = countsDown ? -1 : 1 333 | offsetAnimation = .init(value: offset, velocity: .zero, target: 0) 334 | self.offset = offset 335 | opacityAnimation = .init(value: 0, velocity: .zero, target: 1) 336 | opacity = 0 337 | if delegate?.isBlurEffectEnabled == true { 338 | blurRadiusAnimation = .init(value: appearBlurRadius, velocity: 0, target: 0) 339 | blurRadius = appearBlurRadius 340 | } 341 | updateTransform() 342 | case .disappear: 343 | var scaleAnimation = scaleAnimation ?? .init(value: scale, velocity: .zero, target: Self.smallestScale) 344 | scaleAnimation.target = Self.smallestScale 345 | self.scaleAnimation = scaleAnimation 346 | 347 | let offset: CGFloat = countsDown ? 1 : -1 348 | var offsetAnimation = offsetAnimation ?? .init(value: self.offset, velocity: .zero, target: offset) 349 | offsetAnimation.target = offset 350 | self.offsetAnimation = offsetAnimation 351 | 352 | var opacityAnimation = opacityAnimation ?? .init(value: opacity, velocity: .zero, target: 0) 353 | opacityAnimation.target = 0 354 | self.opacityAnimation = opacityAnimation 355 | 356 | if delegate?.isBlurEffectEnabled == true { 357 | var blurRadiusAnimation = blurRadiusAnimation ?? .init(value: blurRadius, velocity: 0, target: disappearBlurRadius) 358 | blurRadiusAnimation.target = disappearBlurRadius 359 | self.blurRadiusAnimation = blurRadiusAnimation 360 | } 361 | } 362 | } 363 | } 364 | } 365 | 366 | extension GlyphixTextLayer: GlyphixTextLayer.LayerState.Delegate { 367 | 368 | func updateFrame(with state: LayerState) { 369 | let layer = state.layer 370 | let targetFrame = state.presentationFrame 371 | let transform = layer.transform 372 | layer.transform = CATransform3DIdentity 373 | 374 | let currentSize = layer.bounds.size 375 | if currentSize != targetFrame.size { 376 | layer.frame = targetFrame 377 | } else { 378 | layer.position = .init(x: targetFrame.midX, y: targetFrame.midY) 379 | } 380 | layer.transform = transform 381 | } 382 | } 383 | 384 | extension GlyphixTextLayer { 385 | 386 | private func makeLayerState() -> LayerState { 387 | let layer = CALayer() 388 | layer.delegate = self 389 | layer.allowsEdgeAntialiasing = true 390 | layer.needsDisplayOnBoundsChange = true 391 | 392 | #if os(iOS) 393 | let contentsScale: CGFloat = (delegate as? PlatformView)?.window?.screen.scale ?? 2 394 | #elseif os(macOS) 395 | let contentsScale: CGFloat = (delegate as? PlatformView)?.window?.screen?.backingScaleFactor ?? 2 396 | #endif 397 | layer.contentsScale = contentsScale 398 | 399 | var filters: [Any] = [] 400 | if var colorFilter = ColorAddFilter() { 401 | colorFilter.inputColor = colorAnimation.value.cgColor 402 | filters.append(colorFilter.effect) 403 | } 404 | if let blurFilter = GaussianBlurFilter() { 405 | filters.append(blurFilter.effect) 406 | } 407 | layer.filters = filters 408 | 409 | let state = LayerState(layer: layer) 410 | state.delegate = self 411 | return state 412 | } 413 | 414 | private func stateContainer(for glyph: String) -> ArrayContainer { 415 | if let container = glyphStates[glyph] { 416 | return container 417 | } 418 | 419 | let container = ArrayContainer() 420 | glyphStates[glyph] = container 421 | return container 422 | } 423 | 424 | private func setNeedsUpdateTextLayout() { 425 | if let text { 426 | let textLayoutBuilder = TextLayout.Builder( 427 | text: text, 428 | font: effectiveFont, 429 | containerBounds: containerBounds, 430 | alignment: alignment, 431 | lineBreakMode: lineBreakMode, 432 | numberOfLines: numberOfLines 433 | ) 434 | self.textLayout = textLayoutBuilder.build() 435 | } else { 436 | self.textLayout = nil 437 | } 438 | 439 | isLayoutDirty = true 440 | setNeedsLayout() 441 | } 442 | 443 | private func updateTextLayout() { 444 | defer { isLayoutDirty = false } 445 | layerStates.forEach { $1.invalid = true } 446 | 447 | var stateNeedsAppearAnimation: [LayerState] = [] 448 | if let textLayout { 449 | nextGlyph: for placedGlyph in textLayout.placedGlyphs { 450 | let stateKey = placedGlyph.glyphName ?? "" 451 | let glyph = placedGlyph.glyph 452 | let descent = placedGlyph.descent 453 | let boundingRect = placedGlyph.boundingRect 454 | let rect = placedGlyph.layoutRect 455 | let font = placedGlyph.font 456 | if let states = glyphStates[stateKey], !stateKey.isEmpty { 457 | for state in states { 458 | if !state.invalid { 459 | continue 460 | } 461 | 462 | let isInVisibleAnimation = 463 | state.scaleAnimation != nil 464 | || state.opacityAnimation != nil 465 | || state.blurRadiusAnimation != nil 466 | || state.offsetAnimation != nil 467 | 468 | let isAppearing = 469 | state.scaleAnimation?.target == 1 470 | || state.opacityAnimation?.target == 1 471 | || state.blurRadiusAnimation?.target == 0 472 | || state.offsetAnimation?.target == 0 473 | if isAppearing || !isInVisibleAnimation { 474 | state.font = font 475 | state.glyph = glyph 476 | state.descent = descent 477 | state.boundingRect = boundingRect 478 | state.frame = rect 479 | state.invalid = false 480 | state.layer.setNeedsDisplay() 481 | continue nextGlyph 482 | } 483 | } 484 | } 485 | 486 | let state = makeLayerState() 487 | state.font = font 488 | state.glyph = glyph 489 | state.descent = descent 490 | state.boundingRect = boundingRect 491 | state.key = stateKey 492 | let layer = state.layer 493 | addSublayer(layer) 494 | state.presentationFrame = rect 495 | state.frame = rect 496 | stateNeedsAppearAnimation.append(state) 497 | layerStates[layer] = state 498 | 499 | let container = stateContainer(for: stateKey) 500 | container.append(state) 501 | 502 | layer.setNeedsDisplay() 503 | } 504 | } 505 | 506 | if disablesAnimations { 507 | updateLayersToTarget() 508 | return 509 | } 510 | 511 | let invalidStates = layerStates.filter { 512 | $1.invalid 513 | } 514 | 515 | let appearCount = stateNeedsAppearAnimation.count 516 | let disappearCount = invalidStates.count 517 | let length = TimeInterval(max(appearCount, disappearCount)) 518 | let delayInterval: TimeInterval = (length == 0 ? 0 : 0.2 / length) 519 | for (index, state) in stateNeedsAppearAnimation.enumerated() { 520 | state.delay = TimeInterval(index) * delayInterval 521 | state.configureAnimation(with: .appear, countsDown: countsDown) 522 | } 523 | 524 | invalidStates 525 | .sorted { 526 | $0.1.frame.minX < $1.1.frame.minX 527 | } 528 | .enumerated() 529 | .forEach { 530 | let state = $1.1 531 | if !state.isAnimating { 532 | state.delay = TimeInterval($0) * delayInterval 533 | } 534 | state.configureAnimation(with: .disappear, countsDown: countsDown) 535 | } 536 | } 537 | 538 | func updateLayerColor(_ layer: CALayer) { 539 | layer.setValue(colorAnimation.value.cgColor, forKeyPath: ColorAddFilter.inputColorKeyPath) 540 | } 541 | 542 | func animateTransition(with context: VSyncEventContext) { 543 | if disablesAnimations { 544 | return 545 | } 546 | 547 | #if DEBUG && os(iOS) 548 | let animationFactor: TimeInterval = 1 / TimeInterval(UIAnimationDragCoefficient()) 549 | #else 550 | let animationFactor: TimeInterval = 1 551 | #endif 552 | defer { lastFrameTimestamp = context.targetTimestamp } 553 | let duration = 554 | if let lastFrameTimestamp { 555 | Double(context.targetTimestamp - lastFrameTimestamp) * animationFactor 556 | } else { 557 | 0.0 558 | } 559 | if duration == 0 { 560 | return 561 | } 562 | 563 | var needsRedraw = false 564 | var colorAnimation = colorAnimation 565 | if !colorAnimation.isCompleted { 566 | needsRedraw = true 567 | smoothSpring.update( 568 | value: &colorAnimation.value, 569 | velocity: &colorAnimation.velocity, 570 | target: colorAnimation.target, 571 | deltaTime: duration 572 | ) 573 | if colorAnimation.isCompleted { 574 | colorAnimation.value = colorAnimation.target 575 | } 576 | self.colorAnimation = colorAnimation 577 | } 578 | 579 | var removeStates: [LayerState] = .init() 580 | for (_, state) in layerStates { 581 | if needsRedraw { 582 | updateLayerColor(state.layer) 583 | } 584 | updateLayerState(state, deltaTime: duration) 585 | if !state.isVisible, state.invalid { 586 | removeStates.append(state) 587 | } 588 | } 589 | cleanUpStates(removeStates) 590 | } 591 | 592 | func updateLayerState(_ state: LayerState, deltaTime: TimeInterval) { 593 | state.delay -= deltaTime 594 | if state.delay > 0 { return } 595 | 596 | if var frameAnimation = state.frameAnimation { 597 | smoothSpring.update( 598 | value: &frameAnimation.value, 599 | velocity: &frameAnimation.velocity, 600 | target: frameAnimation.target, 601 | deltaTime: deltaTime 602 | ) 603 | state.presentationFrame = frameAnimation.value 604 | if frameAnimation.isCompleted { 605 | state.frame = frameAnimation.target 606 | state.frameAnimation = nil 607 | } else { 608 | state.frameAnimation = frameAnimation 609 | } 610 | } 611 | 612 | if var scaleAnimation = state.scaleAnimation { 613 | snappySpring.update( 614 | value: &scaleAnimation.value, 615 | velocity: &scaleAnimation.velocity, 616 | target: scaleAnimation.target, 617 | deltaTime: deltaTime 618 | ) 619 | state.scale = scaleAnimation.value 620 | if scaleAnimation.isCompleted { 621 | state.scale = scaleAnimation.target 622 | state.scaleAnimation = nil 623 | } else { 624 | state.scaleAnimation = scaleAnimation 625 | } 626 | } 627 | 628 | if var offsetAnimation = state.offsetAnimation { 629 | let bouncy = Spring(response: 0.4, dampingRatio: 0.54) 630 | bouncy.update( 631 | value: &offsetAnimation.value, 632 | velocity: &offsetAnimation.velocity, 633 | target: offsetAnimation.target, 634 | deltaTime: deltaTime 635 | ) 636 | state.offset = offsetAnimation.value 637 | if offsetAnimation.isCompleted { 638 | state.offset = offsetAnimation.target 639 | state.offsetAnimation = nil 640 | } else { 641 | state.offsetAnimation = offsetAnimation 642 | } 643 | } 644 | 645 | state.updateTransform() 646 | 647 | if var opacityAnimation = state.opacityAnimation { 648 | phoneSpring.update( 649 | value: &opacityAnimation.value, 650 | velocity: &opacityAnimation.velocity, 651 | target: opacityAnimation.target, 652 | deltaTime: deltaTime 653 | ) 654 | state.opacity = opacityAnimation.value 655 | if opacityAnimation.isCompleted { 656 | state.opacity = opacityAnimation.target 657 | state.opacityAnimation = nil 658 | } else { 659 | state.opacityAnimation = opacityAnimation 660 | } 661 | } 662 | 663 | if var blurRadiusAnimation = state.blurRadiusAnimation, isBlurEffectEnabled { 664 | bouncySpring.update( 665 | value: &blurRadiusAnimation.value, 666 | velocity: &blurRadiusAnimation.velocity, 667 | target: blurRadiusAnimation.target, 668 | deltaTime: deltaTime 669 | ) 670 | state.blurRadius = blurRadiusAnimation.value 671 | if blurRadiusAnimation.isCompleted { 672 | state.blurRadius = blurRadiusAnimation.target 673 | state.blurRadiusAnimation = nil 674 | } else { 675 | state.blurRadiusAnimation = blurRadiusAnimation 676 | } 677 | } 678 | } 679 | 680 | private func cleanUpStates(_ states: [LayerState]) { 681 | for state in states { 682 | let layer = state.layer 683 | layer.removeFromSuperlayer() 684 | layerStates.removeValue(forKey: layer) 685 | guard let key = state.key else { 686 | continue 687 | } 688 | guard let container = glyphStates[key] else { 689 | continue 690 | } 691 | container.removeAll { $0 === state } 692 | if container.isEmpty { 693 | glyphStates.removeValue(forKey: key) 694 | } 695 | } 696 | } 697 | 698 | /// Directly update the state of the layer to its final state cancel all animations. 699 | private func updateLayersToTarget() { 700 | var needsRemove: [LayerState] = [] 701 | let needsRedraw = !colorAnimation.isCompleted 702 | colorAnimation.velocity = .zero 703 | colorAnimation.value = colorAnimation.target 704 | for (_, state) in layerStates { 705 | if state.invalid { 706 | needsRemove.append(state) 707 | state.layer.removeFromSuperlayer() 708 | continue 709 | } 710 | state.frameAnimation = nil 711 | state.presentationFrame = state.frame 712 | state.scaleAnimation = nil 713 | state.scale = 1 714 | state.offsetAnimation = nil 715 | state.offset = 0 716 | state.opacityAnimation = nil 717 | state.opacity = 1 718 | state.blurRadiusAnimation = nil 719 | state.blurRadius = 0 720 | state.updateTransform() 721 | 722 | let layer = state.layer 723 | 724 | if needsRedraw { 725 | updateLayerColor(layer) 726 | } 727 | 728 | layer.setNeedsDisplay() 729 | layer.displayIfNeeded() 730 | } 731 | cleanUpStates(needsRemove) 732 | } 733 | } 734 | 735 | // MARK: - CALayerDelegate 736 | 737 | extension GlyphixTextLayer: CALayerDelegate { 738 | 739 | public func action(for layer: CALayer, forKey key: String) -> (any CAAction)? { 740 | NSNull() 741 | } 742 | 743 | public func draw(_ layer: CALayer, in ctx: CGContext) { 744 | guard let state = layerStates[layer], 745 | let font = state.font, 746 | var glyph = state.glyph 747 | else { 748 | assertionFailure("invalid layer state") 749 | return 750 | } 751 | 752 | ctx.saveGState() 753 | ctx.setAllowsAntialiasing(true) 754 | ctx.setShouldAntialias(true) 755 | if isSmoothRenderingEnabled { 756 | ctx.setAllowsFontSmoothing(true) 757 | ctx.setShouldSmoothFonts(true) 758 | } 759 | ctx.translateBy(x: 0, y: layer.bounds.height) 760 | ctx.scaleBy(x: 1, y: -1) 761 | 762 | if state.frameAnimation?.isCompleted == false { 763 | // Ensure the glyph is drawn correctly in a scaled layer. 764 | ctx.scaleBy( 765 | x: layer.bounds.width / state.frame.width, 766 | y: layer.bounds.height / state.frame.height 767 | ) 768 | } 769 | 770 | let boundingRect = state.boundingRect 771 | let descent = state.descent 772 | var position: CGPoint = .init(x: -min(0, boundingRect.minX), y: descent - min(0, boundingRect.minY + descent)) 773 | CTFontDrawGlyphs(font, &glyph, &position, 1, ctx) 774 | 775 | ctx.restoreGState() 776 | } 777 | } 778 | -------------------------------------------------------------------------------- /Sources/GlyphixTextFx/Platform.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2024/11/16. 3 | // Copyright (c) 2024 ktiays. All rights reserved. 4 | // 5 | 6 | #if os(iOS) 7 | @_exported import UIKit 8 | 9 | public typealias PlatformColor = UIColor 10 | public typealias PlatformView = UIView 11 | typealias Appearance = UITraitCollection 12 | 13 | extension PlatformColor { 14 | public static var glyphixDefaultColor: PlatformColor { 15 | .label 16 | } 17 | } 18 | 19 | extension Appearance { 20 | static var initialValue = Appearance.current 21 | } 22 | 23 | extension PlatformColor { 24 | func resolvedRgbColor(with traitCollection: UITraitCollection) -> RGBColor { 25 | let cgColor = resolvedColor(with: traitCollection).cgColor.converted( 26 | to: CGColorSpaceCreateDeviceRGB(), 27 | intent: .defaultIntent, 28 | options: nil 29 | )! 30 | let components = cgColor.components! 31 | return .init( 32 | red: Double(components[0]), 33 | green: Double(components[1]), 34 | blue: Double(components[2]), 35 | alpha: Double(cgColor.alpha) 36 | ) 37 | } 38 | } 39 | 40 | #elseif os(macOS) 41 | 42 | @_exported import AppKit 43 | 44 | public typealias PlatformColor = NSColor 45 | public typealias PlatformView = NSView 46 | 47 | typealias Appearance = NSAppearance 48 | 49 | extension PlatformColor { 50 | public static var glyphixDefaultColor: PlatformColor { 51 | .labelColor 52 | } 53 | } 54 | 55 | extension Appearance { 56 | static var initialValue = Appearance.currentDrawing() 57 | } 58 | 59 | extension PlatformColor { 60 | func resolvedRgbColor(with appearance: NSAppearance) -> RGBColor { 61 | var color: RGBColor! 62 | appearance.performAsCurrentDrawingAppearance { 63 | let deviceColor = self.usingColorSpace(.genericRGB)! 64 | color = .init( 65 | red: Double(deviceColor.redComponent), 66 | green: Double(deviceColor.greenComponent), 67 | blue: Double(deviceColor.blueComponent), 68 | alpha: Double(deviceColor.alphaComponent) 69 | ) 70 | } 71 | return color 72 | } 73 | } 74 | #endif 75 | 76 | extension CFRange { 77 | static var zero: Self { 78 | .init(location: 0, length: 0) 79 | } 80 | } 81 | 82 | extension CGSize { 83 | 84 | static var greatestFiniteMagnitude: Self { 85 | .init(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) 86 | } 87 | } 88 | 89 | func ceil(_ size: CGSize) -> CGSize { 90 | .init(width: ceil(size.width), height: ceil(size.height)) 91 | } 92 | 93 | #if DEBUG && os(iOS) 94 | @_silgen_name("UIAnimationDragCoefficient") func UIAnimationDragCoefficient() -> Float 95 | #endif 96 | -------------------------------------------------------------------------------- /Sources/GlyphixTextFx/RGBColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by ktiays on 2024/11/17. 3 | // Copyright (c) 2024 ktiays. All rights reserved. 4 | // 5 | 6 | import Respring 7 | 8 | struct RGBColor: VectorArithmetic { 9 | var red: Double 10 | var green: Double 11 | var blue: Double 12 | var alpha: Double 13 | 14 | var platformColor: PlatformColor { 15 | .init(red: red, green: green, blue: blue, alpha: alpha) 16 | } 17 | 18 | static var zero: RGBColor { 19 | .init(red: 0, green: 0, blue: 0, alpha: 0) 20 | } 21 | 22 | static func + (_ lhs: Self, _ rhs: Self) -> Self { 23 | .init( 24 | red: lhs.red + rhs.red, 25 | green: lhs.green + rhs.green, 26 | blue: lhs.blue + rhs.blue, 27 | alpha: lhs.alpha + rhs.alpha 28 | ) 29 | } 30 | 31 | static func - (_ lhs: Self, _ rhs: Self) -> Self { 32 | .init( 33 | red: lhs.red - rhs.red, 34 | green: lhs.green - rhs.green, 35 | blue: lhs.blue - rhs.blue, 36 | alpha: lhs.alpha - rhs.alpha 37 | ) 38 | } 39 | 40 | mutating func scale(by rhs: Double) { 41 | red *= rhs 42 | green *= rhs 43 | blue *= rhs 44 | alpha *= rhs 45 | } 46 | 47 | var magnitudeSquared: Double { 48 | red * red + green * green + blue * blue + alpha * alpha 49 | } 50 | 51 | var cgColor: CGColor { 52 | .init(red: red, green: green, blue: blue, alpha: alpha) 53 | } 54 | } 55 | 56 | extension RGBColor: ApproximatelyEqual { 57 | static func approximatelyEqual(_ lhs: RGBColor, _ rhs: RGBColor) -> Bool { 58 | CGFloat.approximatelyEqual(lhs.red, rhs.red) 59 | && CGFloat.approximatelyEqual(lhs.green, rhs.green) 60 | && CGFloat.approximatelyEqual(lhs.blue, rhs.blue) 61 | && CGFloat.approximatelyEqual(lhs.alpha, rhs.alpha) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/GlyphixTypesetter/PlacedGlyph.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2025/3/4. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import CoreGraphics 7 | import CoreText 8 | 9 | /// A placed glyphs in a text layout (or a text run). 10 | public struct PlacedGlyph { 11 | 12 | /// The actual font used by this glyph. 13 | public let font: CTFont 14 | 15 | /// The glyph index. 16 | public let glyph: CGGlyph 17 | 18 | /// The glyph name. 19 | public let glyphName: String? 20 | 21 | /// The bounds of the glyph. 22 | public let boundingRect: CGRect 23 | 24 | /// The frame of the glyph in the layout. 25 | public let layoutRect: CGRect 26 | 27 | public let ascent: CGFloat 28 | public let descent: CGFloat 29 | 30 | init( 31 | font: CTFont, 32 | glyph: CGGlyph, 33 | glyphName: String?, 34 | boundingRect: CGRect, 35 | layoutRect: CGRect, 36 | ascent: CGFloat, 37 | descent: CGFloat 38 | ) { 39 | self.font = font 40 | self.glyph = glyph 41 | self.glyphName = glyphName 42 | self.boundingRect = boundingRect 43 | self.layoutRect = layoutRect 44 | self.ascent = ascent 45 | self.descent = descent 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/GlyphixTypesetter/Platform.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2025/3/4. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | #if os(iOS) 7 | import UIKit 8 | 9 | public typealias PlatformFont = UIFont 10 | #elseif os(macOS) 11 | import AppKit 12 | 13 | public typealias PlatformFont = NSFont 14 | #endif 15 | 16 | extension PlatformFont { 17 | 18 | public static var glyphixDefaultFont: PlatformFont { 19 | .systemFont(ofSize: PlatformFont.labelFontSize) 20 | } 21 | } 22 | 23 | extension CFRange { 24 | 25 | @usableFromInline 26 | static var zero: Self { 27 | .init(location: 0, length: 0) 28 | } 29 | } 30 | 31 | func + (_ lhs: CGPoint, _ rhs: CGPoint) -> CGPoint { 32 | .init(x: lhs.x + rhs.x, y: lhs.y + rhs.y) 33 | } 34 | -------------------------------------------------------------------------------- /Sources/GlyphixTypesetter/TextLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Cyandev on 2025/3/4. 3 | // Copyright (c) 2025 ktiays. All rights reserved. 4 | // 5 | 6 | import CoreText 7 | import With 8 | 9 | #if os(iOS) 10 | import UIKit 11 | #else 12 | import AppKit 13 | #endif 14 | 15 | /// An alignment position for text along the horizontal axis. 16 | public enum TextAlignment: CaseIterable { 17 | /// Text is center-aligned. 18 | case center 19 | /// Text is left-aligned. 20 | case leading 21 | /// Text is right-aligned. 22 | case trailing 23 | } 24 | 25 | public class TextLayout { 26 | 27 | /// A type for generating the text layout. 28 | public struct Builder { 29 | 30 | /// The text. 31 | public var text: String 32 | 33 | /// The font for the text. 34 | public var font: PlatformFont 35 | 36 | /// The bounding rectangle of the text container. 37 | public var containerBounds: CGRect 38 | 39 | /// The technique for aligning the text. 40 | /// 41 | /// The default value for this property is `.left`. 42 | public var alignment: TextAlignment 43 | 44 | /// The technique for wrapping and truncating the text. 45 | /// 46 | /// The default value for this property is `.byTruncatingTail`. 47 | public var lineBreakMode: NSLineBreakMode 48 | 49 | /// The maximum number of lines for the text. 50 | /// 51 | /// A value of 0 indicates that the number of lines is limitless. 52 | /// The default value for this property is `1`. 53 | public var numberOfLines: Int 54 | 55 | public init( 56 | text: String, 57 | font: PlatformFont, 58 | containerBounds: CGRect, 59 | alignment: TextAlignment = .leading, 60 | lineBreakMode: NSLineBreakMode = .byTruncatingTail, 61 | numberOfLines: Int = 1 62 | ) { 63 | self.text = text 64 | self.font = font 65 | self.containerBounds = containerBounds 66 | self.alignment = alignment 67 | self.lineBreakMode = lineBreakMode 68 | self.numberOfLines = numberOfLines 69 | } 70 | } 71 | 72 | @usableFromInline 73 | var framesetter: CTFramesetter 74 | 75 | /// The placed glyphs. 76 | public private(set) var placedGlyphs: [PlacedGlyph] 77 | 78 | /// The size of the bounding box that contains all the glyphs. 79 | public private(set) var size: CGSize 80 | 81 | fileprivate init(framesetter: CTFramesetter, placedGlyphs: [PlacedGlyph], size: CGSize) { 82 | self.framesetter = framesetter 83 | self.placedGlyphs = placedGlyphs 84 | self.size = size 85 | } 86 | 87 | @inlinable 88 | public func size(fitting constrainedSize: CGSize) -> CGSize { 89 | return CTFramesetterSuggestFrameSizeWithConstraints( 90 | framesetter, 91 | .zero, 92 | nil, 93 | constrainedSize, 94 | nil 95 | ) 96 | } 97 | } 98 | 99 | extension TextLayout.Builder { 100 | 101 | private var needsLastLineTruncation: Bool { 102 | switch lineBreakMode { 103 | case .byTruncatingTail, .byTruncatingHead, .byTruncatingMiddle, .byClipping: 104 | true 105 | default: 106 | false 107 | } 108 | } 109 | 110 | /// Creates an immutable text layout. 111 | public func build() -> TextLayout { 112 | let paragraphStyle = NSMutableParagraphStyle() 113 | paragraphStyle.lineBreakMode = needsLastLineTruncation ? .byWordWrapping : lineBreakMode 114 | let attributedText = NSAttributedString( 115 | string: text, 116 | attributes: [ 117 | .font: font, 118 | .paragraphStyle: paragraphStyle, 119 | ] 120 | ) 121 | 122 | let framesetter = CTFramesetterCreateWithAttributedString(attributedText) 123 | let containerPath = CGPath(rect: .init(origin: .zero, size: containerBounds.size), transform: nil) 124 | 125 | let ctFrame = CTFramesetterCreateFrame(framesetter, .zero, containerPath, nil) 126 | var lines = CTFrameGetLines(ctFrame) as! [CTLine] 127 | var isLastLineTruncated: Bool = false 128 | if numberOfLines > 0 && lines.count > numberOfLines { 129 | lines.removeSubrange(numberOfLines...) 130 | isLastLineTruncated = true 131 | } 132 | if needsLastLineTruncation && !isLastLineTruncated { 133 | let visibleRange = CTFrameGetVisibleStringRange(ctFrame) 134 | isLastLineTruncated = (visibleRange.length != attributedText.length && !lines.isEmpty) 135 | } 136 | 137 | if needsLastLineTruncation && isLastLineTruncated { 138 | performLineTruncation(lines: &lines, attributedText: attributedText) 139 | } 140 | 141 | struct LineTraits { 142 | var ascent: CGFloat 143 | var descent: CGFloat 144 | var bounds: CGRect 145 | } 146 | 147 | var lineTraits: [LineTraits] = [] 148 | var textBounds: CGRect = .zero 149 | for line in lines { 150 | var ascent: CGFloat = 0 151 | var descent: CGFloat = 0 152 | let width = min(containerBounds.width, CTLineGetTypographicBounds(line, &ascent, &descent, nil)) 153 | let height = ascent + descent 154 | textBounds.size.width = max(textBounds.width, width) 155 | textBounds.size.height += height 156 | lineTraits.append( 157 | .init( 158 | ascent: ascent, 159 | descent: descent, 160 | bounds: .init(x: 0, y: textBounds.height - height, width: width, height: height) 161 | ) 162 | ) 163 | } 164 | 165 | // Place glyphs. 166 | var placedGlyphs = [PlacedGlyph]() 167 | for (lineIndex, line) in lines.enumerated() { 168 | let (lineAscent, lineDescent, lineBounds) = with(lineTraits[lineIndex]) { 169 | ($0.ascent, $0.descent, $0.bounds) 170 | } 171 | let alignmentHorizontalOffset: CGFloat = 172 | switch alignment { 173 | case .leading: 174 | 0 175 | case .center: 176 | (containerBounds.width - lineBounds.width) / 2 177 | case .trailing: 178 | containerBounds.width - lineBounds.width 179 | } 180 | let alignmentVerticalOffset = (containerBounds.height - textBounds.height) / 2 181 | let lineOrigin = lineBounds.origin + CGPoint(x: alignmentHorizontalOffset, y: alignmentVerticalOffset) 182 | 183 | let runs = CTLineGetGlyphRuns(line) as! [CTRun] 184 | for run in runs { 185 | let attributes = CTRunGetAttributes(run) as! [NSAttributedString.Key: Any] 186 | // When the specified font is unable to render the text correctly, `CoreText` will automatically 187 | // match an appropriate system font based on the characters for display. 188 | // As a result, the font used to render this text may not be unique. 189 | let runFont = (attributes[.font] as? PlatformFont ?? font) as CTFont 190 | let cgFont = CTFontCopyGraphicsFont(runFont, nil) 191 | let glyphCount = CTRunGetGlyphCount(run) 192 | var positions: [CGPoint] = .init(repeating: .zero, count: glyphCount) 193 | CTRunGetPositions(run, .zero, &positions) 194 | // Stores the glyph advances (widths), representing the horizontal distance to 195 | // the next character for precise text layout and width calculation. 196 | var advances: [CGSize] = .init(repeating: .zero, count: glyphCount) 197 | CTRunGetAdvances(run, .zero, &advances) 198 | 199 | // Optimization - batch allocate the space for the run. 200 | placedGlyphs.reserveCapacity(placedGlyphs.count + glyphCount) 201 | 202 | var glyphs = [CGGlyph](repeating: 0, count: glyphCount) 203 | CTRunGetGlyphs(run, .zero, &glyphs) 204 | var boundingRects: [CGRect] = .init(repeating: .zero, count: glyphCount) 205 | CTFontGetBoundingRectsForGlyphs(font, .default, &glyphs, &boundingRects, glyphCount) 206 | for (glyphIndex, glyph) in glyphs.enumerated() { 207 | let glyphName = cgFont.name(for: glyph) as? String 208 | let position = positions[glyphIndex] 209 | let advance = advances[glyphIndex] 210 | let boundingRect = boundingRects[glyphIndex] 211 | // Correction value in the x-axis direction, as character rendering may exceed the grid area, 212 | // requiring the left-side x to store a value indicating the necessary offset. 213 | let xCompensation = min(0, boundingRect.minX) 214 | let bottomExtends = min(0, boundingRect.minY + lineDescent) 215 | let topExtends = max(0, boundingRect.maxY + lineDescent - lineBounds.height) 216 | var rect = CGRect( 217 | x: lineOrigin.x + position.x + xCompensation, 218 | y: lineOrigin.y + position.y - topExtends, 219 | width: ceil(max(advance.width, boundingRect.maxX)), 220 | height: lineBounds.height - bottomExtends + topExtends 221 | ) 222 | if rect.minX > containerBounds.width { 223 | break 224 | } 225 | if rect.maxX > containerBounds.width { 226 | rect.size.width = containerBounds.width - rect.minX 227 | } 228 | 229 | placedGlyphs.append( 230 | .init( 231 | font: runFont, 232 | glyph: glyph, 233 | glyphName: glyphName, 234 | boundingRect: boundingRect, 235 | layoutRect: rect, 236 | ascent: lineAscent, 237 | descent: lineDescent 238 | ) 239 | ) 240 | } 241 | } 242 | } 243 | 244 | return .init( 245 | framesetter: framesetter, 246 | placedGlyphs: placedGlyphs, 247 | size: textBounds.size 248 | ) 249 | } 250 | 251 | @inline(__always) 252 | private func makeAttributedString(_ text: String) -> NSAttributedString { 253 | .init(string: text, attributes: [.font: font]) 254 | } 255 | 256 | @inline(__always) 257 | private func performLineTruncation(lines: inout [CTLine], attributedText: NSAttributedString) { 258 | guard let lastLine = lines.last else { 259 | return 260 | } 261 | 262 | // Truncation processing is required for the last line. 263 | let lineCFRange = CTLineGetStringRange(lastLine) 264 | var lineRange = NSRange(location: lineCFRange.location, length: lineCFRange.length) 265 | let needsAdditionalTruncation = (lineBreakMode == .byTruncatingTail) 266 | if !needsAdditionalTruncation { 267 | lineRange.length = attributedText.length - lineRange.location 268 | } 269 | let lastLineString: NSMutableAttributedString = .init(attributedString: attributedText.attributedSubstring(from: lineRange)) 270 | let truncationTokenString = makeAttributedString("\u{2026}") 271 | if needsAdditionalTruncation { 272 | lastLineString.append(truncationTokenString) 273 | } 274 | let line = CTLineCreateWithAttributedString(lastLineString) 275 | 276 | if lineBreakMode == .byClipping { 277 | lines[lines.count - 1] = line 278 | return 279 | } 280 | 281 | let truncationLine = CTLineCreateWithAttributedString(truncationTokenString) 282 | let truncationType: CTLineTruncationType = 283 | switch lineBreakMode { 284 | case .byTruncatingHead: 285 | .start 286 | case .byTruncatingMiddle: 287 | .middle 288 | default: 289 | .end 290 | } 291 | if let truncatedLine = CTLineCreateTruncatedLine( 292 | line, 293 | containerBounds.width, 294 | truncationType, 295 | truncationLine 296 | ) { 297 | lines[lines.count - 1] = truncatedLine 298 | } 299 | } 300 | } 301 | --------------------------------------------------------------------------------