├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE.txt ├── Package.swift ├── README.md ├── Sources └── TCAnimatedText │ ├── AnimatedText.swift │ └── LibraryContent.swift ├── Tests ├── LinuxMain.swift └── TCAnimatedTextTests │ ├── TCAnimatedTextTests.swift │ └── XCTestManifests.swift └── images └── AnimatedText_Cover.gif /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jean-Marc Boullianne 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. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 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: "TCAnimatedText", 8 | platforms: [ 9 | .iOS(.v14), 10 | .macOS(.v10_16) 11 | ], 12 | products: [ 13 | // Products define the executables and libraries a package produces, and make them visible to other packages. 14 | .library( 15 | name: "TCAnimatedText", 16 | targets: ["TCAnimatedText"]), 17 | ], 18 | dependencies: [ 19 | // Dependencies declare other packages that this package depends on. 20 | // .package(url: /* package url */, from: "1.0.0"), 21 | ], 22 | targets: [ 23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 24 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 25 | .target( 26 | name: "TCAnimatedText", 27 | dependencies: []), 28 | .testTarget( 29 | name: "TCAnimatedTextTests", 30 | dependencies: ["TCAnimatedText"]), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TCAnimatedText 2 | 3 | ![License](https://img.shields.io/github/license/jboullianne/TCAnimatedText) ![Stars](https://img.shields.io/github/stars/jboullianne/TCAnimatedText?style=social) ![Followers](https://img.shields.io/github/followers/jboullianne?style=social) ![Forks](https://img.shields.io/github/forks/jboullianne/TCAnimatedText?style=social) 4 | 5 | Written for [TrailingClosure.com](https://trailingclosure.com/). 6 | 7 | > TCAnimatedText is a SwiftUI package that adds animations to ordinary `Text` views. When the input string of the `AnimatedText` changes, your text will come to life and showcase it's change with a wonderful animation. 8 | 9 | ![Title](images/AnimatedText_Cover.gif?v=4&s=200) 10 | 11 | ## Usage 12 | 13 | ```swift 14 | // Three Parameters: 15 | // -- String Input 16 | // -- Character Change Duration (seconds) 17 | // -- Modifier Closure to change text style directly 18 | AnimatedText($input, charDuration: 0.07) { text in 19 | text 20 | .font(.largeTitle) 21 | .fontWeight(.bold) 22 | .foregroundColor(.green) 23 | } 24 | ``` 25 | -------------------------------------------------------------------------------- /Sources/TCAnimatedText/AnimatedText.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by Jean-Marc Boullianne on 6/26/20. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @available (iOS 14.0, macOS 10.16, *) 11 | public struct AnimatedText: View { 12 | 13 | var charDuration: Double 14 | @Binding var input: String 15 | @State var string: String 16 | @State var isUpdating: Bool 17 | @State var nextValue: String? 18 | var textModifier: (Text)->(Text) 19 | 20 | public init(_ input: Binding, charDuration: Double, modifier: @escaping (Text)->(Text)) { 21 | self._input = input 22 | self._string = State(initialValue: input.wrappedValue) 23 | self._isUpdating = State(initialValue: false) 24 | self.charDuration = charDuration 25 | self.textModifier = modifier 26 | 27 | } 28 | 29 | public var body: some View { 30 | HStack(alignment: .center, spacing: 0) { 31 | ForEach(Array(string.enumerated()), id: \.0) { (n, ch) in 32 | self.textModifier(Text(String(ch))) 33 | 34 | } 35 | }.onChange(of: input) { newValue in 36 | if self.string != newValue && !self.isUpdating{ 37 | self.nextValue = newValue 38 | self.isUpdating = true 39 | animateStringChange(newValue: newValue) 40 | } 41 | 42 | if let nextValue = self.nextValue, nextValue != newValue && self.isUpdating { 43 | self.string = nextValue 44 | self.nextValue = newValue 45 | animateStringChange(newValue: newValue) 46 | } 47 | } 48 | } 49 | 50 | func animateStringChange(newValue: String) { 51 | 52 | let output = needlemanWunsch(input1: self.string, input2: newValue) 53 | let old = output.output1 54 | let new = output.output2 55 | 56 | var operation: ((Int, Int, String, String) -> ())? 57 | 58 | 59 | operation = { (i, offset, old, new) in 60 | guard let nextValue = nextValue, nextValue == newValue && i < old.count else { return } 61 | 62 | if old[i] == "-" { 63 | let ch = new[i] 64 | let index = self.string.index(self.string.startIndex, offsetBy: i) 65 | 66 | DispatchQueue.main.asyncAfter(deadline: .now() + charDuration) { 67 | self.string.insert(contentsOf: ch, at: index) 68 | operation?(i + 1, offset, old, new) 69 | } 70 | 71 | } else if new[i] == "-" { 72 | let index = self.string.index(self.string.startIndex, offsetBy: i + offset) 73 | DispatchQueue.main.asyncAfter(deadline: .now() + charDuration) { 74 | self.string.remove(at: index) 75 | operation?(i + 1, offset - 1, old, new) 76 | } 77 | } else if old[i] != new[i] { 78 | let ch = new[i] 79 | let startIndex = self.string.index(self.string.startIndex, offsetBy: i + offset) 80 | let endIndex = self.string.index(self.string.startIndex, offsetBy: i + 1 + offset) 81 | DispatchQueue.main.asyncAfter(deadline: .now() + charDuration) { 82 | self.string.replaceSubrange(startIndex.. (output1: String, output2: String, score: Int) { 96 | 97 | enum Origin { case top, left, diagonal } 98 | 99 | let seq1 = Array(input1) // Horizontal, so its length sets number of columns (j) 100 | let seq2 = Array(input2) // Vertical, so its length sets number of rows (i) 101 | 102 | var scores: [[Int]] = [] 103 | var paths: [[[Origin]]] = [] 104 | 105 | // Initialize both matrixes with zeros. 106 | for _ in 0...seq2.count { 107 | scores.append(Array(repeatElement(0, count: seq1.count + 1))) 108 | paths.append(Array(repeatElement([], count: seq1.count + 1))) 109 | } 110 | 111 | // Initialize first rows and columns. 112 | for j in 1...seq1.count { 113 | scores[0][j] = scores[0][j - 1] + gap 114 | paths[0][j] = [.left] 115 | } 116 | for i in 1...seq2.count { 117 | scores[i][0] = scores[i - 1][0] + gap 118 | paths[i][0] = [.top] 119 | } 120 | 121 | // Populate the rest of both matrices. 122 | for i in 1...seq2.count { 123 | for j in 1...seq1.count { 124 | let fromTop = scores[i - 1][j] + gap 125 | let fromLeft = scores[i][j - 1] + gap 126 | let fromDiagonal = scores[i - 1][j - 1] + (seq1[j - 1] == seq2[i - 1] ? match : substitution) 127 | let fromMax = max(fromTop, fromLeft, fromDiagonal) 128 | 129 | scores[i][j] = fromMax 130 | 131 | if fromDiagonal == fromMax { paths[i][j].append(.diagonal) } 132 | if fromTop == fromMax { paths[i][j].append(.top) } 133 | if fromLeft == fromMax { paths[i][j].append(.left) } 134 | } 135 | } 136 | 137 | // Get the alignment representation. 138 | var output1 = "" 139 | var output2 = "" 140 | 141 | var i = seq2.count 142 | var j = seq1.count 143 | 144 | while !(i == 0 && j == 0) { 145 | switch paths[i][j].first! { 146 | case .diagonal: 147 | output1 = String(seq1[j - 1]) + output1 148 | output2 = String(seq2[i - 1]) + output2 149 | i -= 1 150 | j -= 1 151 | case .top: 152 | output1 = "-" + output1 153 | output2 = String(seq2[i - 1]) + output2 154 | i -= 1 155 | case .left: 156 | output1 = String(seq1[j - 1]) + output1 157 | output2 = "-" + output2 158 | j -= 1 159 | } 160 | } 161 | 162 | return (output1, output2, scores[seq2.count][seq1.count]) 163 | } 164 | } 165 | 166 | public extension String { 167 | subscript(i: Int) -> String { 168 | return String(self[index(startIndex, offsetBy: i)]) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /Sources/TCAnimatedText/LibraryContent.swift: -------------------------------------------------------------------------------- 1 | 2 | import SwiftUI 3 | 4 | @available (iOS 14.0, macOS 10.16, *) 5 | public struct LibraryContent: LibraryContentProvider { 6 | 7 | @State var tmp: String = "" 8 | 9 | @LibraryContentBuilder 10 | public var views: [LibraryItem] { 11 | LibraryItem( 12 | AnimatedText($tmp, charDuration: 0.2) { text in 13 | text 14 | }, 15 | visible: true, 16 | title: "TC Animated Text", 17 | category: LibraryItem.Category.layout 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import TCAnimatedTextTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += TCAnimatedTextTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/TCAnimatedTextTests/TCAnimatedTextTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import TCAnimatedText 3 | 4 | final class TCAnimatedTextTests: XCTestCase { 5 | func testExample() { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | } 10 | 11 | static var allTests = [ 12 | ("testExample", testExample), 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /Tests/TCAnimatedTextTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(TCAnimatedTextTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /images/AnimatedText_Cover.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jboullianne/TCAnimatedText/60370debe99c299e9409b73c4e7c8b7d63be2532/images/AnimatedText_Cover.gif --------------------------------------------------------------------------------