├── .gitignore
├── .swiftpm
└── xcode
│ ├── package.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
│ └── xcshareddata
│ └── xcschemes
│ └── Marquee.xcscheme
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── Marquee
│ ├── Marquee.swift
│ └── Utils.swift
└── Tests
├── LinuxMain.swift
└── MarqueeTests
├── MarqueeTests.swift
└── XCTestManifests.swift
/.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 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/Marquee.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
38 |
39 |
44 |
45 |
47 |
53 |
54 |
55 |
56 |
57 |
67 |
68 |
74 |
75 |
81 |
82 |
83 |
84 |
86 |
87 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 SwiftUIKit
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: "Marquee",
8 | platforms: [.iOS(.v14), .macOS(.v11), .tvOS(.v14), .watchOS(.v7)],
9 | products: [
10 | // Products define the executables and libraries a package produces, and make them visible to other packages.
11 | .library(
12 | name: "Marquee",
13 | targets: ["Marquee"]),
14 | ],
15 | dependencies: [
16 | // Dependencies declare other packages that this package depends on.
17 | // .package(url: /* package url */, from: "1.0.0"),
18 | ],
19 | targets: [
20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
21 | // Targets can depend on other targets in this package, and on products in packages this package depends on.
22 | .target(
23 | name: "Marquee",
24 | dependencies: []),
25 | .testTarget(
26 | name: "MarqueeTests",
27 | dependencies: ["Marquee"]),
28 | ]
29 | )
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Marquee
2 |
3 | A powerful implementation of Marquee(scrolling text or label) in SwiftUI, which supports any content view, including text(label), image, video, etc.
4 |
5 | 
6 |
7 | Blog [SwiftUI: How to create a powerful Marquee?](https://catchzeng.medium.com/swiftui-how-to-create-a-powerful-marquee-625446c5197a)
8 |
9 | ## Features
10 |
11 | - [x] Supports any content view powered by [ViewBuilder](https://developer.apple.com/documentation/swiftui/viewbuilder).
12 | - [x] Supports **autoreverses**.
13 | - [x] Supports custom **duration**.
14 | - [x] Supports custom **direction**.
15 | - [x] left2right
16 | - [x] right2left
17 | - [x] Marquee **when content view not fit**.
18 |
19 | ## Installation
20 |
21 | ### Swift Package Manager
22 |
23 | In Xcode go to `File -> Swift Packages -> Add Package Dependency` and paste in the repo's url: .
24 |
25 | ## Usage
26 |
27 | ### Any Content View
28 |
29 | #### Text(Label)
30 |
31 | 
32 |
33 | ```swift
34 | import SwiftUI
35 | import Marquee
36 |
37 | struct ContentView: View {
38 | var body: some View {
39 | Marquee {
40 | Text("Hello World!")
41 | .fontWeight(.bold)
42 | .font(.system(size: 40))
43 | }
44 | }
45 | }
46 | ```
47 |
48 | #### Image
49 |
50 | 
51 |
52 | ```swift
53 | import SwiftUI
54 | import Marquee
55 |
56 | struct ContentView: View {
57 | var body: some View {
58 | Marquee {
59 | Image("test")
60 | }
61 | }
62 | }
63 | ```
64 |
65 | #### AttributedText
66 |
67 | 
68 |
69 | ```swift
70 | import SwiftUI
71 | import Marquee
72 |
73 | struct ContentView: View {
74 | var body: some View {
75 | Marquee {
76 | Text("Bold")
77 | .fontWeight(.bold)
78 | .font(.custom("SFUIDisplay-Light", size: 40))
79 | +
80 | Text(" Underlined")
81 | .font(.system(size: 30))
82 | .underline()
83 | .foregroundColor(.red)
84 | + Text(" Color")
85 | .foregroundColor(.blue)
86 | }
87 | }
88 | }
89 | ```
90 |
91 | ### Animation Options
92 |
93 | - duration
94 |
95 | 
96 |
97 | > Specially, when `duration` is equal to `0` or `Double.infinity`, the animation will stop and the view stays at the initial position.
98 |
99 | 
100 |
101 | - autoreverses
102 |
103 | 
104 |
105 | - direction
106 |
107 | 
108 |
109 | - whenNotFit
110 |
111 | 
112 |
113 | ```swift
114 | import SwiftUI
115 | import Marquee
116 |
117 | struct ContentView: View {
118 | @State var duration: Double = 3.0
119 | @State var autoreverses: Bool = false
120 | @State var direction: MarqueeDirection = .right2left
121 | @State var whenNotFit: Bool = false
122 |
123 | var body: some View {
124 | VStack {
125 | Marquee {
126 | VStack {
127 | Text("Bold")
128 | .fontWeight(.bold)
129 | .font(.custom("SFUIDisplay-Light", size: 40))
130 | +
131 | Text(" Underlined")
132 | .font(.system(size: 30))
133 | .underline()
134 | .foregroundColor(.red)
135 | + Text(" Color cccccccccccccccc")
136 | .foregroundColor(.blue)
137 | }
138 | }.background(Color.white)
139 | .marqueeDuration(duration)
140 | .marqueeAutoreverses(autoreverses)
141 | .marqueeDirection(direction)
142 | .marqueeWhenNotFit(whenNotFit)
143 | .marqueeIdleAlignment(.leading)
144 |
145 | Spacer()
146 |
147 | HStack {
148 | Button(action: {
149 | self.duration = (duration == 3.0) ? 1.0 : 3.0
150 | }, label: {
151 | Text("duration")
152 | })
153 |
154 | Button(action: {
155 | self.autoreverses.toggle()
156 | }, label: {
157 | Text("autoreverses")
158 | })
159 |
160 | Button(action: {
161 | self.direction = (direction == .left2right) ? .right2left : .left2right
162 | }, label: {
163 | Text("direction")
164 | })
165 |
166 | Button(action: {
167 | self.whenNotFit.toggle()
168 | }, label: {
169 | Text("whenNotFit")
170 | })
171 |
172 | }.frame(height: 100)
173 | }.ignoresSafeArea()
174 | }
175 | }
176 | ```
177 |
178 | ## ChangeLog
179 |
180 | ### v0.3.0
181 |
182 | - add idle alignment.
183 |
184 | ### v0.2.1
185 |
186 | - fix vertical alignment.
187 |
188 | ### v0.2.0
189 |
190 | - add marquee **when content view not fit**.
191 |
192 | ### v0.1.0
193 |
194 | - add marquee.
195 |
--------------------------------------------------------------------------------
/Sources/Marquee/Marquee.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Marquee.swift
3 | //
4 | //
5 | // Created by CatchZeng on 2020/11/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | public enum MarqueeDirection {
11 | case right2left
12 | case left2right
13 | }
14 |
15 | private enum MarqueeState {
16 | case idle
17 | case ready
18 | case animating
19 | }
20 |
21 | public struct Marquee : View where Content : View {
22 | @Environment(\.marqueeDuration) var duration
23 | @Environment(\.marqueeAutoreverses) var autoreverses: Bool
24 | @Environment(\.marqueeDirection) var direction: MarqueeDirection
25 | @Environment(\.marqueeWhenNotFit) var stopWhenNotFit: Bool
26 | @Environment(\.marqueeIdleAlignment) var idleAlignment: HorizontalAlignment
27 |
28 | private var content: () -> Content
29 | @State private var state: MarqueeState = .idle
30 | @State private var contentWidth: CGFloat = 0
31 | @State private var isAppear = false
32 |
33 | public init(@ViewBuilder content: @escaping () -> Content) {
34 | self.content = content
35 | }
36 |
37 | public var body: some View {
38 | GeometryReader { proxy in
39 | VStack {
40 | if isAppear {
41 | content()
42 | .background(GeometryBackground())
43 | .fixedSize()
44 | .myOffset(x: offsetX(proxy: proxy), y: 0)
45 | .frame(maxHeight: .infinity)
46 | } else {
47 | Text("")
48 | }
49 | }
50 | .onPreferenceChange(WidthKey.self, perform: { value in
51 | self.contentWidth = value
52 | resetAnimation(duration: duration, autoreverses: autoreverses, proxy: proxy)
53 | })
54 | .onAppear {
55 | self.isAppear = true
56 | resetAnimation(duration: duration, autoreverses: autoreverses, proxy: proxy)
57 | }
58 | .onDisappear {
59 | self.isAppear = false
60 | }
61 | .onChange(of: duration) { [] newDuration in
62 | resetAnimation(duration: newDuration, autoreverses: self.autoreverses, proxy: proxy)
63 | }
64 | .onChange(of: autoreverses) { [] newAutoreverses in
65 | resetAnimation(duration: self.duration, autoreverses: newAutoreverses, proxy: proxy)
66 | }
67 | .onChange(of: direction) { [] _ in
68 | resetAnimation(duration: duration, autoreverses: autoreverses, proxy: proxy)
69 | }
70 | }.clipped()
71 | }
72 |
73 | private func offsetX(proxy: GeometryProxy) -> CGFloat {
74 | switch self.state {
75 | case .idle:
76 | switch idleAlignment {
77 | case .center:
78 | return 0.5*(proxy.size.width-contentWidth)
79 | case .trailing:
80 | return proxy.size.width-contentWidth
81 | default:
82 | return 0
83 | }
84 | case .ready:
85 | return (direction == .right2left) ? proxy.size.width : -contentWidth
86 | case .animating:
87 | return (direction == .right2left) ? -contentWidth : proxy.size.width
88 | }
89 | }
90 |
91 | private func resetAnimation(duration: Double, autoreverses: Bool, proxy: GeometryProxy) {
92 | if duration == 0 || duration == Double.infinity {
93 | stopAnimation()
94 | } else {
95 | startAnimation(duration: duration, autoreverses: autoreverses, proxy: proxy)
96 | }
97 | }
98 |
99 | private func startAnimation(duration: Double, autoreverses: Bool, proxy: GeometryProxy) {
100 | let isNotFit = contentWidth < proxy.size.width
101 | if stopWhenNotFit && isNotFit {
102 | stopAnimation()
103 | return
104 | }
105 |
106 | withAnimation(.instant) {
107 | self.state = .ready
108 | withAnimation(Animation.linear(duration: duration).repeatForever(autoreverses: autoreverses)) {
109 | self.state = .animating
110 | }
111 | }
112 | }
113 |
114 | private func stopAnimation() {
115 | withAnimation(.instant) {
116 | self.state = .idle
117 | }
118 | }
119 | }
120 |
121 | struct Marquee_Previews: PreviewProvider {
122 | static var previews: some View {
123 | Marquee {
124 | Text("Hello World!")
125 | .fontWeight(.bold)
126 | .font(.system(size: 40))
127 | }
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/Sources/Marquee/Utils.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Utils.swift
3 | //
4 | //
5 | // Created by CatchZeng on 2020/11/23.
6 | //
7 |
8 | import Foundation
9 | import CoreGraphics
10 | import SwiftUI
11 |
12 | struct DurationKey: EnvironmentKey {
13 | static var defaultValue: Double = 2.0
14 | }
15 |
16 | struct AutoreversesKey: EnvironmentKey {
17 | static var defaultValue: Bool = false
18 | }
19 |
20 | struct DirectionKey: EnvironmentKey {
21 | static var defaultValue: MarqueeDirection = .right2left
22 | }
23 |
24 | struct StopWhenNotFitKey: EnvironmentKey {
25 | static var defaultValue: Bool = false
26 | }
27 |
28 | struct AlignmentKey: EnvironmentKey {
29 | static var defaultValue: HorizontalAlignment = .leading
30 | }
31 |
32 | extension EnvironmentValues {
33 | var marqueeDuration: Double {
34 | get {self[DurationKey.self]}
35 | set {self[DurationKey.self] = newValue}
36 | }
37 |
38 | var marqueeAutoreverses: Bool {
39 | get {self[AutoreversesKey.self]}
40 | set {self[AutoreversesKey.self] = newValue}
41 | }
42 |
43 | var marqueeDirection: MarqueeDirection {
44 | get {self[DirectionKey.self]}
45 | set {self[DirectionKey.self] = newValue}
46 | }
47 |
48 | var marqueeWhenNotFit: Bool {
49 | get {self[StopWhenNotFitKey.self]}
50 | set {self[StopWhenNotFitKey.self] = newValue}
51 | }
52 |
53 | var marqueeIdleAlignment: HorizontalAlignment {
54 | get {self[AlignmentKey.self]}
55 | set {self[AlignmentKey.self] = newValue}
56 | }
57 | }
58 |
59 | public extension View {
60 | /// Sets the marquee animation duration to the given value.
61 | ///
62 | /// Marquee {
63 | /// Text("Hello World!")
64 | /// }.marqueeDuration(3.0)
65 | ///
66 | /// - Parameters:
67 | /// - duration: Animation duration, default is `2.0`.
68 | ///
69 | /// - Returns: A view that has the given value set in its environment.
70 | func marqueeDuration(_ duration: Double) -> some View {
71 | environment(\.marqueeDuration, duration)
72 | }
73 |
74 | /// Sets the marquee animation autoreverses to the given value.
75 | ///
76 | /// Marquee {
77 | /// Text("Hello World!")
78 | /// }.marqueeAutoreverses(true)
79 | ///
80 | /// - Parameters:
81 | /// - autoreverses: Animation autoreverses, default is `false`.
82 | ///
83 | /// - Returns: A view that has the given value set in its environment.
84 | func marqueeAutoreverses(_ autoreverses: Bool) -> some View {
85 | environment(\.marqueeAutoreverses, autoreverses)
86 | }
87 |
88 | /// Sets the marquee animation direction to the given value.
89 | ///
90 | /// Marquee {
91 | /// Text("Hello World!")
92 | /// }.marqueeDirection(.right2left)
93 | ///
94 | /// - Parameters:
95 | /// - direction: `MarqueeDirection` enum, default is `right2left`.
96 | ///
97 | /// - Returns: A view that has the given value set in its environment.
98 | func marqueeDirection(_ direction: MarqueeDirection) -> some View {
99 | environment(\.marqueeDirection, direction)
100 | }
101 |
102 | /// Stop the marquee animation when the content view is not fit`(contentWidth < marqueeWidth)`.
103 | ///
104 | /// Marquee {
105 | /// Text("Hello World!")
106 | /// }.marqueeWhenNotFit(true)
107 | ///
108 | /// - Parameters:
109 | /// - stopWhenNotFit: Stop the marquee animation when the content view is not fit(`contentWidth < marqueeWidth`), default is `false`.
110 | ///
111 | /// - Returns: A view that has the given value set in its environment.
112 | func marqueeWhenNotFit(_ stopWhenNotFit: Bool) -> some View {
113 | environment(\.marqueeWhenNotFit, stopWhenNotFit)
114 | }
115 |
116 | /// Sets the marquee alignment when idle(stop animation).
117 | ///
118 | /// Marquee {
119 | /// Text("Hello World!")
120 | /// }.marqueeIdleAlignment(.center)
121 | ///
122 | /// - Parameters:
123 | /// - alignment: Alignment when idle(stop animation), default is `.leading`.
124 | ///
125 | /// - Returns: A view that has the given value set in its environment.
126 | func marqueeIdleAlignment(_ alignment: HorizontalAlignment) -> some View {
127 | environment(\.marqueeIdleAlignment, alignment)
128 | }
129 | }
130 |
131 | struct GeometryBackground: View {
132 | var body: some View {
133 | GeometryReader { geometry in
134 | return Color.clear.preference(key: WidthKey.self, value: geometry.size.width)
135 | }
136 | }
137 | }
138 |
139 | struct WidthKey: PreferenceKey {
140 | static var defaultValue = CGFloat(0)
141 |
142 | static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
143 | value = nextValue()
144 | }
145 |
146 | typealias Value = CGFloat
147 | }
148 |
149 | extension Animation {
150 | static var instant: Animation {
151 | return .linear(duration: 0.01)
152 | }
153 | }
154 |
155 | // Refference: https://swiftui-lab.com/swiftui-animations-part2/
156 |
157 | extension View {
158 | func myOffset(x: CGFloat, y: CGFloat) -> some View {
159 | return modifier(_OffsetEffect(offset: CGSize(width: x, height: y)))
160 | }
161 |
162 | func myOffset(_ offset: CGSize) -> some View {
163 | return modifier(_OffsetEffect(offset: offset))
164 | }
165 | }
166 |
167 | struct _OffsetEffect: GeometryEffect {
168 | var offset: CGSize
169 |
170 | var animatableData: CGSize.AnimatableData {
171 | get { CGSize.AnimatableData(offset.width, offset.height) }
172 | set { offset = CGSize(width: newValue.first, height: newValue.second) }
173 | }
174 |
175 | public func effectValue(size: CGSize) -> ProjectionTransform {
176 | return ProjectionTransform(CGAffineTransform(translationX: offset.width, y: offset.height))
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/Tests/LinuxMain.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | import MarqueeTests
4 |
5 | var tests = [XCTestCaseEntry]()
6 | tests += MarqueeTests.allTests()
7 | XCTMain(tests)
8 |
--------------------------------------------------------------------------------
/Tests/MarqueeTests/MarqueeTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import Marquee
3 |
4 | final class MarqueeTests: 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 | // XCTAssertEqual(Marquee().text, "Hello, World!")
10 | }
11 |
12 | static var allTests = [
13 | ("testExample", testExample),
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/Tests/MarqueeTests/XCTestManifests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | #if !canImport(ObjectiveC)
4 | public func allTests() -> [XCTestCaseEntry] {
5 | return [
6 | testCase(MarqueeTests.allTests),
7 | ]
8 | }
9 | #endif
10 |
--------------------------------------------------------------------------------