├── .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 |    
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 | 
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
--------------------------------------------------------------------------------