├── .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 | ![](https://github.com/SwiftUIKit/assets/blob/master/Marquee/demo.gif?raw=true) 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 | ![](https://github.com/SwiftUIKit/assets/blob/master/Marquee/text.gif?raw=true) 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 | ![](https://github.com/SwiftUIKit/assets/blob/master/Marquee/image.gif?raw=true) 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 | ![](https://github.com/SwiftUIKit/assets/blob/master/Marquee/attributedText.gif?raw=true) 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 | ![](https://github.com/SwiftUIKit/assets/blob/master/Marquee/duration.gif?raw=true) 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 | ![](https://github.com/SwiftUIKit/assets/blob/master/Marquee/idle.png?raw=true) 100 | 101 | - autoreverses 102 | 103 | ![](https://github.com/SwiftUIKit/assets/blob/master/Marquee/autoreverses.gif?raw=true) 104 | 105 | - direction 106 | 107 | ![](https://github.com/SwiftUIKit/assets/blob/master/Marquee/direction.gif?raw=true) 108 | 109 | - whenNotFit 110 | 111 | ![](https://github.com/SwiftUIKit/assets/blob/master/Marquee/whenNotFit.gif?raw=true) 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 | --------------------------------------------------------------------------------