Created
March 17, 2022 18:40
-
-
Save cyypherus/a4302aff0ba79aae27ebd7d22e8a6115 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
import Foundation | |
import Combine | |
struct ContentView: View { | |
static let defaultIcons: [Icon] = [ | |
.init(), | |
.init(), | |
.init(), | |
.init(), | |
.init(), | |
.init(), | |
.init(), | |
.init(), | |
.init(), | |
.init() | |
] | |
struct Icon: Identifiable { | |
let id = UUID() | |
} | |
@State var visibleIndices: [Array<Icon>.Index] | |
let icons: [Icon] | |
let rate: Double | |
var timer: Timer.TimerPublisher | |
// Caveat - if window size is big enough you'll see icons animate from the beginning to the end as the window rolls. | |
// The window needs to be small enough that the icons will be fully removed before they're re-added at the end | |
init(_ icons: [Icon] = Self.defaultIcons, rate: Double = 1, rollingWindowSize: Int = 7) { | |
self.icons = icons | |
// Insertion and removal is animated at the edges of the marquee - this rolls the window so we still see zero at the beginning, even though we shifted the HStack to hide removal | |
var visible = icons.indices[0..<rollingWindowSize].map { $0 } | |
visible.retreatWindow(icons.indices.map { $0 }) | |
self.visibleIndices = visible | |
self.rate = rate | |
self.timer = Timer.publish(every: rate, on: .main, in: .default) | |
} | |
var body: some View { | |
HStack(spacing: 10) { | |
ForEach(visibleIndices, id: \.self) { i in | |
RoundedRectangle(cornerRadius: 21) | |
.frame(width: 95, height: 95) | |
.foregroundColor(.indigo) | |
.overlay { | |
Text("\(i)") | |
} | |
} | |
} | |
// SwiftUI will automatically .center align if things don't fit in their container. | |
// These two .frame modifiers trick swiftUI so it thinks it fits so that it will .leading align 😈 | |
// Second frame modifier fills width - we don't actually want zero width | |
// This layout can be accomplished with a geometry reader as well | |
.frame(width: 0, alignment: .leading) | |
.frame(maxWidth: .infinity, alignment: .leading) | |
// Insertion and removal is animated at the edges of the marquee - this hides it off screen | |
.offset(x: -105) | |
.onReceive(timer) { _ in | |
advanceMarquee() | |
} | |
.onAppear { // So that you don't have to wait a second for it to begin scrolling | |
advanceMarquee() | |
_ = timer.connect() // Start timer | |
} | |
} | |
func advanceMarquee() { | |
withAnimation(.linear(duration: rate)) { | |
visibleIndices.advanceWindow(icons.indices.map { $0 }) | |
} | |
} | |
} | |
extension Array where Element == Int { | |
mutating func advanceWindow(_ source: [Int]) { | |
if let last = last, | |
source.indices.contains(last + 1) { | |
removeFirst() | |
append(last + 1) | |
} else if let first = source.first { | |
removeFirst() | |
append(first) | |
} | |
} | |
mutating func retreatWindow(_ source: [Int]) { | |
if let first = first, | |
source.indices.contains(first - 1) { | |
removeLast() | |
insert(first - 1, at: 0) | |
} else if let last = source.last { | |
removeLast() | |
insert(last, at: 0) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment