The idea is this: an object has a start position (A) and an end position (B). When the start button is pressed, the object moves from Ax, Ay to Bx, By. At an arbitrary point in time, you can stop its movement and continue.
I tried to implement this with a timer that moves the object every n time periods. It works, but the problem is that the timer eats up a lot of memory and when ten objects are created, the application freezes.
I do not ask you to do the task for me, but I will be very grateful if you tell me where to dig
Code currently in use
typealias RemainingDurationProvider<Value: VectorArithmetic> = (Value) -> TimeInterval
typealias AnimationWithDurationProvider = (TimeInterval) -> Animation
extension Animation {
static let instant = Animation.linear(duration: 0.0001)
}
struct PausableAnimationModifier<Value: VectorArithmetic>: AnimatableModifier {
#Binding var binding: Value
#Binding var paused: Bool
private let targetValue: Value
private let remainingDuration: RemainingDurationProvider<Value>
private let animation: AnimationWithDurationProvider
var animatableData: Value
init(binding: Binding<Value>, targetValue: Value, remainingDuration: #escaping RemainingDurationProvider<Value>, animation: #escaping AnimationWithDurationProvider, paused: Binding<Bool>) {
_binding = binding
self.targetValue = targetValue
self.remainingDuration = remainingDuration
self.animation = animation
_paused = paused
animatableData = binding.wrappedValue
}
func body(content: Content) -> some View {
content
.onChange(of: paused) { isPaused in
if isPaused {
withAnimation(.instant) {
binding = animatableData
}
} else {
withAnimation(animation(remainingDuration(animatableData))) {
binding = targetValue
}
}
}
}
}
extension View {
func pausableAnimation<Value: VectorArithmetic>(binding: Binding<Value>, targetValue: Value, remainingDuration: #escaping RemainingDurationProvider<Value>, animation: #escaping AnimationWithDurationProvider, paused: Binding<Bool>) -> some View {
self.modifier(PausableAnimationModifier(binding: binding, targetValue: targetValue, remainingDuration: remainingDuration, animation: animation, paused: paused))
}
}
struct TestView: View {
#State private var isPaused = false
#State private var offsetX: Double = .zero
#State private var startOffsetX: Double = .zero
#State private var endOffsetX: Double = 200.0
#State private var offsetY: Double = .zero
#State private var startPositionY: Double = .zero
#State private var endPositionY: Double = 500.0
private let duration: TimeInterval = 6
private var remainingDurationForX: RemainingDurationProvider<Double> {
{ currentAngle in duration * (1 - (currentAngle - startOffsetX) / (endOffsetX - startOffsetX)) }
}
private var remainingDurationForY: RemainingDurationProvider<Double> {
{ currentAngle in duration * (1 - (currentAngle - startPositionY) / (endPositionY - startPositionY)) }
}
private let animation: AnimationWithDurationProvider = { duration in
.linear(duration: duration)
}
var body: some View {
VStack {
ZStack {
VStack {
HStack {
Rectangle()
.frame(width: 50, height: 50)
.offset(x: offsetX, y: offsetY)
.pausableAnimation(binding: $offsetX,
targetValue: endOffsetX,
remainingDuration: remainingDurationForX,
animation: animation,
paused: $isPaused)
.pausableAnimation(binding: $offsetY,
targetValue: endPositionY,
remainingDuration: remainingDurationForY,
animation: animation,
paused: $isPaused)
Spacer()
}
Spacer()
}
}
HStack {
ControllButton(text: “Start”, action: {
offsetX = startOffsetX
offsetY = startPositionY
withAnimation(animation(duration)) {
offsetX = endOffsetX
offsetY = endPositionY
}
})
ControllButton(text: “NewPosition”, action: {
startOffsetX = endOffsetX
startPositionY = endPositionY
endOffsetX = 300
endPositionY = 30
})
ControllButton(text: isPaused ? “Resume” : “Pause”, action: {
isPaused = !isPaused
})
ControllButton(text: “Stop”, action: {
offsetX = .zero
offsetY = .zero
})
}
.padding(.bottom)
}
}
}
struct ControllButton: View {
var text: String
var action: () -> ()
var body: some View {
Button(text) {
action()
}
.padding()
.background(Color.yellow)
.frame(height: 35)
.cornerRadius(10)
}
}
Not sure the exact intent of your code, but here's something you could try:
struct ContentView: View {
#State var recs: [(AnyView, UUID)] = []
#State var isPaused: Bool = true
#State var shouldUpdate: Bool = false
var body: some View {
VStack {
ZStack {
ForEach(recs, id: \.1) { $0.0 }
}
Spacer()
HStack {
Button(isPaused ? "Start" : "Pause") {
isPaused.toggle()
}
Button("Add") {
recs.append(
(AnyView(
Rectangle()
.frame(width: 50, height: 50)
.pausableAnimation(
startingPosition: .init(x: .random(in: 0..<300), y: 0),
endingPosition: .init(x: .random(in: 0..<300), y: .random(in: 300..<700)),
isPaused: $isPaused,
shoudUpdate: $shouldUpdate
)
), UUID())
)
}
Button("Update") {
shouldUpdate = true
}
Button("Reset") {
isPaused = true
recs.removeAll()
}
}
.buttonStyle(.borderedProminent)
.tint(.orange)
}
}
}
extension CGPoint {
func isCloseTo(_ other: CGPoint) -> Bool {
(self.x - other.x) * (self.x - other.x) + (self.y - other.y) * (self.y - other.y) < 10
}
}
struct PausableAnimation: ViewModifier {
#State var startingPosition: CGPoint
#State var endingPosition: CGPoint
#Binding var isPaused: Bool
#Binding var shouldUpdate: Bool
private let publisher = Timer.TimerPublisher(interval: 0.1, runLoop: .main, mode: .default).autoconnect()
#State private var fired = 0
func body(content: Content) -> some View {
content
.position(startingPosition)
.animation(.linear, value: startingPosition)
.onReceive(publisher) { _ in
if !isPaused && !startingPosition.isCloseTo(endingPosition){
startingPosition.x += (endingPosition.x - startingPosition.x) / CGFloat(20 - fired)
startingPosition.y += (endingPosition.y - startingPosition.y) / CGFloat(20 - fired)
fired += 1
}
}
.onChange(of: shouldUpdate) { newValue in
if newValue == true {
updatePosition()
shouldUpdate = false
}
}
}
func updatePosition() {
endingPosition = .init(x: .random(in: 0..<300), y: .random(in: 0..<700))
fired = 0
}
}
extension View {
func pausableAnimation(startingPosition: CGPoint, endingPosition: CGPoint, isPaused: Binding<Bool>, shoudUpdate: Binding<Bool>) -> some View {
modifier(
PausableAnimation(
startingPosition: startingPosition,
endingPosition: endingPosition,
isPaused: isPaused,
shouldUpdate: shoudUpdate
)
)
}
}
Basically the idea here is in each PausableAnimation, a TimerPublisher is created to fire every 0.1 second. Anytime the publisher fires, it will move from startingPosition and endingPosition by 1/20 of the distance between them.
I used a CGPoint to keep both x and y information in a single variable. However, using two separate variables shouldn't change much of the code beside needing to pass in more data in the initializer.
I wrapped the modified view in a (AnyView, UUID), so I can add more of them into an array and display them through ForEach, dynamically.
Feel free to play around, I hope the idea is clear.
Related
What I expect to happen
I have a meditation view that has an animation subview with a binding property inhaling that should appear when a button is pressed.
When the animation subview appears, it should start the animation from the beginning. It's the Apple meditation breathing animation basically: it starts as a small ball and gets bigger as inhaling is true, and then smaller as inhaling is false.
When the user presses the button again, the animation should disappear.
When the user then again presses the button, a second time, it should start the animation subview with a binding clean. Meaning the subview is a small ball and gets big again. Like the first time.
struct Meditation: View {
#State var startBreathingAnimation = false
#State var inhaling = false
#State var infoText = "Start a mediation"
var body: some View {
VStack(spacing: 20) {
ZStack {
if startBreathingAnimation {
BreathAnimation(inhaling: $inhaling)
.onChange(of: inhaling) { newValue in
if newValue {
infoText = "Breath in..."
} else {
infoText = "Breath out..."
} }
.onDisappear {
infoText = "Start your meditation" // Never executed?
}
} else {
Circle()
.frame(height: 100)
.foregroundColor(.blue)
}
}
Text(infoText)
Button("Toggle") {
startBreathingAnimation.toggle()
}
}
.padding()
}
}
What actually happens
The animation subview with a binding is not reset, newly initialized, but starts just where it left off after being "dismissed" with the button press.
When I don't add a binding property into the subview, it actually works as expected: it resets every time and gives me a "fresh" subview. But I do actually need to observe changes to the animation subview property inhaling in order to update the infoText property in the main view.
Reproducible example code, ready to copy into Xcode
Any help is greatly appreciated!
// Can be copied to Xcode directly
struct Meditation: View {
#State var startBreathingAnimation = false
#State var inhaling = false
#State var infoText = "Start a mediation"
var body: some View {
VStack(spacing: 20) {
ZStack {
if startBreathingAnimation {
BreathAnimation(inhaling: $inhaling)
.onChange(of: inhaling) { newValue in
if newValue {
infoText = "Breath in..."
} else {
infoText = "Breath out..."
} }
.onDisappear {
infoText = "Start your meditation" // Never executed?
}
} else {
Circle()
.frame(height: 100)
.foregroundColor(.blue)
}
}
Text(infoText)
Button("Toggle") {
startBreathingAnimation.toggle()
}
}
.padding()
}
}
private let gradientStart = Color.accentColor.opacity(0.9)
private let gradientEnd = Color.accentColor.opacity(1.0)
private let gradient = LinearGradient(gradient: Gradient(colors: [gradientStart, gradientEnd]), startPoint: .top, endPoint: .bottom)
private let maskGradient = LinearGradient(gradient: Gradient(colors: [.black]), startPoint: .top, endPoint: .bottom)
private let maxSize: CGFloat = 150
private let minSize: CGFloat = 30
private let inhaleTime: Double = 8
private let exhaleTime: Double = 8
private let pauseTime: Double = 1.5
private let numberOfPetals = 4
private let bigAngle = 360 / numberOfPetals
private let smallAngle = bigAngle / 2
private let ghostMaxSize: CGFloat = maxSize * 0.99
private let ghostMinSize: CGFloat = maxSize * 0.95
private struct Petals: View {
let size: CGFloat
let inhaling: Bool
var isMask = false
var body: some View {
let petalsGradient = isMask ? maskGradient : gradient
ZStack {
ForEach(0..<numberOfPetals) { index in
petalsGradient
.frame(maxWidth: .infinity, maxHeight: .infinity)
.mask(
Circle()
.frame(width: size, height: size)
.offset(x: inhaling ? size * 0.5 : 0)
.rotationEffect(.degrees(Double(bigAngle * index)))
)
.blendMode(isMask ? .normal : .screen)
}
}
}
}
struct BreathAnimation: View {
#State private var size = minSize
#Binding var inhaling: Bool
#State private var ghostSize = ghostMaxSize
#State private var ghostBlur: CGFloat = 0
#State private var ghostOpacity: Double = 0
var body: some View {
ZStack {
// Color.black
// .edgesIgnoringSafeArea(.all)
ZStack {
// ghosting for exhaling
Petals(size: ghostSize, inhaling: inhaling)
.blur(radius: ghostBlur)
.opacity(ghostOpacity)
// the mask is important, otherwise there is a color
// 'jump' when exhaling
Petals(size: size, inhaling: inhaling, isMask: true)
// overlapping petals
Petals(size: size, inhaling: inhaling)
Petals(size: size, inhaling: inhaling)
.rotationEffect(.degrees(Double(smallAngle)))
.opacity(inhaling ? 0.8 : 0.6)
}
.rotationEffect(.degrees(Double(inhaling ? bigAngle : -smallAngle)))
.drawingGroup()
}
.onAppear {
performAnimations()
}
.onDisappear {
size = minSize
inhaling = false
ghostSize = ghostMaxSize
ghostBlur = 0
ghostOpacity = 0
}
}
func performAnimations() {
withAnimation(.easeInOut(duration: inhaleTime)) {
inhaling = true
size = maxSize
}
Timer.scheduledTimer(withTimeInterval: inhaleTime + pauseTime, repeats: false) { _ in
ghostSize = ghostMaxSize
ghostBlur = 0
ghostOpacity = 0.8
Timer.scheduledTimer(withTimeInterval: exhaleTime * 0.2, repeats: false) { _ in
withAnimation(.easeOut(duration: exhaleTime * 0.6)) {
ghostBlur = 30
ghostOpacity = 0
}
}
withAnimation(.easeInOut(duration: exhaleTime)) {
inhaling = false
size = minSize
ghostSize = ghostMinSize
}
}
Timer.scheduledTimer(withTimeInterval: inhaleTime + pauseTime + exhaleTime + pauseTime, repeats: false) { _ in
// endless animation!
performAnimations()
}
}
private func performAnimations2() {
withAnimation(.easeInOut(duration: inhaleTime)) {
inhaling = true
size = maxSize
}
Timer.scheduledTimer(withTimeInterval: inhaleTime + pauseTime, repeats: false) { _ in
ghostSize = ghostMaxSize
ghostBlur = 0
ghostOpacity = 0.8
Timer.scheduledTimer(withTimeInterval: exhaleTime * 0.2, repeats: false) { _ in
withAnimation(.easeOut(duration: exhaleTime * 0.6)) {
ghostBlur = 30
ghostOpacity = 0
}
}
withAnimation(.easeInOut(duration: exhaleTime)) {
inhaling = false
size = minSize
ghostSize = ghostMinSize
}
}
Timer.scheduledTimer(withTimeInterval: inhaleTime + pauseTime + exhaleTime + pauseTime, repeats: false) { _ in
// endless animation!
performAnimations()
}
}
}
struct MainView_Previews: PreviewProvider {
static var previews: some View {
Meditation()
}
}
Here is a possible approach by setting a specific .id for the view and changing it on reset, forcing a redraw of the subview:
struct ContentView: View {
#State var startBreathingAnimation = false
#State var inhaling = false
#State var infoText = "Start a mediation"
#State var viewID = UUID()
var body: some View {
VStack(spacing: 20) {
ZStack {
if startBreathingAnimation {
BreathAnimation(inhaling: $inhaling)
.id(viewID) // here
.onChange(of: inhaling) { newValue in
if newValue {
infoText = "Breath in..."
} else {
infoText = "Breath out..."
}
}
} else {
Circle()
.frame(height: 100)
.foregroundColor(.blue)
}
}
Text(infoText)
Button("Toggle") {
if startBreathingAnimation {
startBreathingAnimation = false
infoText = "Start your meditation"
inhaling = false
} else {
startBreathingAnimation = true
viewID = UUID()
}
}
}
.padding()
}
}
As an extension to ChrisR's answer, which helped give me a fresh subview, but created the problem of out-of-sync animation property values, I used the help of PreferenceKeys. PreferenceKeys are apparently not that known among many intermediate SwiftUI devs, so I thought I'd share it here briefly.
Swiftful Thinking has a great video on them: link to video
A binding to a subview and its parent creates a way to strong connection for my case. I only want to observe the inhaling property of BreathAnimation() on my MainView().
That's when PreferenceKeys come into play.
Here is the code that helped me solve my issue.
Create a property that can be accessed from all views if needed:
struct InhalingPreferenceKey: PreferenceKey {
static var defaultValue: Bool = false
static func reduce(value: inout Bool, nextValue: () -> Bool) {
value = nextValue()
}
}
// Housekeeping that lets us update the preference key in our childview
extension View {
func updateInhalingPreferenceKey(_ isInhaling: Bool) -> some View {
preference(key: InhalingPreferenceKey.self, value: isInhaling)
}
}
Add this to the childview and connect it to the BreathAnimation property inhaling:
var body: some View {
VStack {
// Content of child view
}
.updateInhalingPreferenceKey(inhaling)
}
And finally, we can access the childview property by using this:
.onPreferenceChange(InhalingPreferenceKey.self, perform: { inhaling in
self.inhaling = inhaling
}) // self.inhaling is the parentview property
This together with ChrisR's solution for fresh child views helped me achieve what I wanted. Hope this might help someone else as well!
I am adding the possibility to swipe in order to update a barchart. What I want to show is statistics for different station. To view different station I want the user to be able to swipe between the stations. I can see that the swiping works and each time I swipe I get the correct data from my controller. The problem is that my view is not redrawn properly.
I found this guide, but cannot make it work.
Say I swipe right from station 0 with data [100, 100, 100] to station 2, the retrieved data from my controller is [0.0, 100.0, 0.0]. The view I have still is for [100, 100, 100]`.
The station number is correctly updated, so I suspect it needs some state somehow.
Here is the code:
import SwiftUI
import SwiftUICharts
struct DetailedResultsView: View {
#ObservedObject var viewModel: ViewModel = .init()
#State private var tabIndex: Int = 0
#State private var startPos: CGPoint = .zero
#State private var isSwiping = true
var body: some View {
VStack {
Text("Station \(viewModel.getStation() + 1)")
TabView(selection: $tabIndex) {
BarCharts(data: viewModel.getData(kLatestRounds: 10, station: viewModel.getStation()), disciplineName: viewModel.getName()).tabItem { Group {
Image(systemName: "chart.bar")
Text("Last 10 Sessions")
}}.tag(0)
}
}.gesture(DragGesture()
.onChanged { gesture in
if self.isSwiping {
self.startPos = gesture.location
self.isSwiping.toggle()
}
}
.onEnded { gesture in
if gesture.location.x - startPos.x > 10 {
viewModel.decrementStation()
}
if gesture.location.x - startPos.x < -10 {
viewModel.incrementStation()
}
}
)
}
}
struct BarCharts: View {
var data: [Double]
var title: String
init(data: [Double], disciplineName: String) {
self.data = data
title = disciplineName
print(data)
}
var body: some View {
VStack {
BarChartView(data: ChartData(points: self.data), title: self.title, style: Styles.barChartStyleOrangeLight, form: CGSize(width: 300, height: 400))
}
}
}
class ViewModel: ObservableObject {
#Published var station = 1
let controller = DetailedViewController()
var isPreview = false
func getData(kLatestRounds: Int, station: Int) -> [Double] {
if isPreview {
return [100.0, 100.0, 100.0]
} else {
let data = controller.getResults(kLatestRounds: kLatestRounds, station: station, fileName: userDataFile)
return data
}
}
func getName() -> String {
controller.getDiscipline().name
}
func getNumberOfStations() -> Int {
controller.getDiscipline().getNumberOfStations()
}
func getStation() -> Int {
station
}
func incrementStation() {
station = (station + 1) % getNumberOfStations()
}
func decrementStation() {
station -= 1
if station < 0 {
station = getNumberOfStations() - 1
}
}
}
The data is printed inside the constructor each time I swipe. Shouldn't that mean it should be updated?
I don’t use SwiftUICharts so I can’t test it, but the least you can try is manually set the id to the view
struct DetailedResultsView: View {
#ObservedObject var viewModel: ViewModel = .init()
#State private var tabIndex: Int = 0
#State private var startPos: CGPoint = .zero
#State private var isSwiping = true
var body: some View {
VStack {
Text("Station \(viewModel.getStation() + 1)")
TabView(selection: $tabIndex) {
BarCharts(data: viewModel.getData(kLatestRounds: 10, station: viewModel.getStation()), disciplineName: viewModel.getName())
.id(viewmodel.station) // here. If it doesn’t work, you can set it to the whole TabView
.tabItem { Group {
Image(systemName: "chart.bar")
Text("Last 10 Sessions")
}}.tag(0)
}
}.gesture(DragGesture()
.onChanged { gesture in
if self.isSwiping {
self.startPos = gesture.location
self.isSwiping.toggle()
}
}
.onEnded { gesture in
if gesture.location.x - startPos.x > 10 {
viewModel.decrementStation()
}
if gesture.location.x - startPos.x < -10 {
viewModel.incrementStation()
}
}
)
}
}
Tried several way to make it bubble effect from bottom to top moving.
I able to move it from bottom to top. but I can not make it like bubble effect.
#State private var bouncing = true
var body: some View {
Image("bubble").resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 40)
.frame(maxHeight: .infinity, alignment: bouncing ? .bottom : .top)
.animation(Animation.easeInOut(duration: 5.0).repeatForever(autoreverses: false))
.onAppear {
self.bouncing.toggle()
}
}
Here is simple bubble animation what I looking for.
import SwiftUI
struct MyParentView: View {
#State var replay: Bool = false
var body: some View {
ZStack{
Color.blue.opacity(0.8)
BubbleEffectView(replay: $replay)
VStack{
Spacer()
Button(action: {
replay.toggle()
}, label: {Text("replay")}).foregroundColor(.red)
}
}
}
}
struct BubbleEffectView: View {
#StateObject var viewModel: BubbleEffectViewModel = BubbleEffectViewModel()
#Binding var replay: Bool
var body: some View {
GeometryReader{ geo in
ZStack{
//Show bubble views for each bubble
ForEach(viewModel.bubbles){bubble in
BubbleView(bubble: bubble)
}
}.onChange(of: replay, perform: { _ in
viewModel.addBubbles(frameSize: geo.size)
})
.onAppear(){
//Set the initial position from frame size
viewModel.viewBottom = geo.size.height
viewModel.addBubbles(frameSize: geo.size)
}
}
}
}
class BubbleEffectViewModel: ObservableObject{
#Published var viewBottom: CGFloat = CGFloat.zero
#Published var bubbles: [BubbleViewModel] = []
private var timer: Timer?
private var timerCount: Int = 0
#Published var bubbleCount: Int = 50
func addBubbles(frameSize: CGSize){
let lifetime: TimeInterval = 2
//Start timer
timerCount = 0
if timer != nil{
timer?.invalidate()
}
timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { (timer) in
let bubble = BubbleViewModel(height: 10, width: 10, x: frameSize.width/2, y: self.viewBottom, color: .white, lifetime: lifetime)
//Add to array
self.bubbles.append(bubble)
//Get rid if the bubble at the end of its lifetime
Timer.scheduledTimer(withTimeInterval: bubble.lifetime, repeats: false, block: {_ in
self.bubbles.removeAll(where: {
$0.id == bubble.id
})
})
if self.timerCount >= self.bubbleCount {
//Stop when the bubbles will get cut off by screen
timer.invalidate()
self.timer = nil
}else{
self.timerCount += 1
}
}
}
}
struct BubbleView: View {
//If you want to change the bubble's variables you need to observe it
#ObservedObject var bubble: BubbleViewModel
#State var opacity: Double = 0
var body: some View {
Circle()
.foregroundColor(bubble.color)
.opacity(opacity)
.frame(width: bubble.width, height: bubble.height)
.position(x: bubble.x, y: bubble.y)
.onAppear {
withAnimation(.linear(duration: bubble.lifetime)){
//Go up
self.bubble.y = -bubble.height
//Go sideways
self.bubble.x += bubble.xFinalValue()
//Change size
let width = bubble.yFinalValue()
self.bubble.width = width
self.bubble.height = width
}
//Change the opacity faded to full to faded
//It is separate because it is half the duration
DispatchQueue.main.asyncAfter(deadline: .now()) {
withAnimation(.linear(duration: bubble.lifetime/2).repeatForever()) {
self.opacity = 1
}
}
DispatchQueue.main.asyncAfter(deadline: .now()) {
withAnimation(Animation.linear(duration: bubble.lifetime/4).repeatForever()) {
//Go sideways
//bubble.x += bubble.xFinalValue()
}
}
}
}
}
class BubbleViewModel: Identifiable, ObservableObject{
let id: UUID = UUID()
#Published var x: CGFloat
#Published var y: CGFloat
#Published var color: Color
#Published var width: CGFloat
#Published var height: CGFloat
#Published var lifetime: TimeInterval = 0
init(height: CGFloat, width: CGFloat, x: CGFloat, y: CGFloat, color: Color, lifetime: TimeInterval){
self.height = height
self.width = width
self.color = color
self.x = x
self.y = y
self.lifetime = lifetime
}
func xFinalValue() -> CGFloat {
return CGFloat.random(in:-width*CGFloat(lifetime*2.5)...width*CGFloat(lifetime*2.5))
}
func yFinalValue() -> CGFloat {
return CGFloat.random(in:0...width*CGFloat(lifetime*2.5))
}
}
struct MyParentView_Previews: PreviewProvider {
static var previews: some View {
MyParentView()
}
}
I have tried with the following code
struct ContentView: View {
#State var show = false
var body: some View {
ScrollView {
LazyVStack(alignment: .center, spacing: 0) {
ForEach(1...100, id: \.self) { index in
if self.show {
Text("Placeholder \(index)")
.padding(24)
.opacity(1)
.transition(
AnyTransition.opacity.animation(
Animation
.easeOut(duration: 0.6)
.delay(Double(index) * 0.15)
.repeatForever(autoreverses: true)
)
)
}
}
}
}.onAppear {
self.show = true
}
}
}
This works fine for the first iteration, but for the next iterations the delay is accumulated wrongly.
Wanted effect (first one on the left). Result (last one on the right).
Here would be a working solution.
The idea is to handle repeating forever manually with a timer.
The scrolling also works:
One just has to wait a bit, since the delay increases linearly with Double(index)*0.15.
Code:
import SwiftUI
import Combine
final class AnimationManager: ObservableObject {
#Published var timer: AnyCancellable!
#Published var show: Bool = false
init() {
timer = Timer.publish(every: 0.75, on: .main, in: .common)
.autoconnect()
.sink { _ in
self.show.toggle()
}
}
}
struct ContentView: View {
#StateObject private var animationManager = AnimationManager()
var body: some View {
ScrollView {
LazyVStack(alignment: .center, spacing: 0) {
ForEach(1...100, id: \.self) { index in
Text("Placeholder \(index)")
.padding(24)
.opacity(animationManager.show ? 1.0 : 0.1)
.animation(Animation.easeOut(duration: 0.6).delay(Double(index)*0.15))
}
}
}
}
}
Additional
You might want to use a VStack instead of LazyVStack. This would get rid of the increasing delay of elements down in the list since they all appear immediately. I don't know what your desired effect is.
Wrapping this in a UIView seems to work and give desired effect
struct PulsatingView<Content: View>: UIViewRepresentable {
var maxOpacity = 0.7
var minOpacity = 0.2
var duration = 0.5
var delay = 0.05
var content: Content
func makeUIView(context: Context) -> ViewContainer<Content> {
let view = ViewContainer(child: content)
return view
}
func updateUIView(_ container: ViewContainer<Content>, context: Context) {
let anim = CABasicAnimation()
anim.fromValue = minOpacity
anim.toValue = maxOpacity
anim.duration = duration
anim.autoreverses = true
anim.timingFunction = .init(name: .easeInEaseOut)
anim.repeatCount = Float.greatestFiniteMagnitude
anim.timeOffset = -delay
anim.keyPath = "opacity"
container.child.rootView = content
container.layer.add(anim, forKey: "pulsating")
}
}
class ViewContainer<Content: View>: UIView {
var child: UIHostingController<Content>
init(child: Content) {
self.child = UIHostingController(rootView: child)
super.init(frame: .zero)
addSubview(self.child.view)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
child.view.bounds = bounds
child.view.center = CGPoint(x: bounds.width/2.0, y: bounds.height/2.0)
}
override var intrinsicContentSize: CGSize {
child.view.sizeThatFits(CGSize(width: Double.greatestFiniteMagnitude, height: Double.greatestFiniteMagnitude))
}
}
struct ContentView: View {
var body: some View {
ScrollView {
LazyVStack(alignment: .center, spacing: 0) {
ForEach(1...1000, id: \.self) { index in
PulsatingView(
delay: Double(index) * 0.15,
content: {
Text("Placeholder \(index)")
.padding(24)
}()
)
}
}
}
}
}
But there's some bugs with vertical placement when used in conjunction with LazyVStack 🤨
I have text but it's not fit. I want use marquee when text not fit in my default frame.
Text(self.viewModel.soundTrack.title)
.font(.custom("Avenir Next Regular", size: 24))
.multilineTextAlignment(.trailing)
.lineLimit(1)
.foregroundColor(.white)
.fixedSize(horizontal: false, vertical: true)
//.frame(width: 200.0, height: 30.0)
Try below code....
In MarqueeText.swift
import SwiftUI
struct MarqueeText: View {
#State private var leftMost = false
#State private var w: CGFloat = 0
#State private var previousText: String = ""
#State private var contentViewWidth: CGFloat = 0
#State private var animationDuration: Double = 5
#Binding var text : String
var body: some View {
let baseAnimation = Animation.linear(duration: self.animationDuration)//Animation duration
let repeated = baseAnimation.repeatForever(autoreverses: false)
return VStack(alignment:.center, spacing: 0) {
GeometryReader { geometry in//geometry.size.width will provide container/superView width
Text(self.text).font(.system(size: 24)).lineLimit(1).foregroundColor(.clear).fixedSize(horizontal: true, vertical: false).background(TextGeometry()).onPreferenceChange(WidthPreferenceKey.self, perform: {
self.w = $0
print("textWidth:\(self.w)")
print("geometry:\(geometry.size.width)")
self.contentViewWidth = geometry.size.width
if self.text.count != self.previousText.count && self.contentViewWidth < self.w {
let duration = self.w/50
print("duration:\(duration)")
self.animationDuration = Double(duration)
self.leftMost = true
} else {
self.animationDuration = 0.0
}
self.previousText = self.text
}).fixedSize(horizontal: false, vertical: true)// This Text is temp, will not be displayed in UI. Used to identify the width of the text.
if self.animationDuration > 0.0 {
Text(self.text).font(.system(size: 24)).lineLimit(nil).foregroundColor(.green).fixedSize(horizontal: true, vertical: false).background(TextGeometry()).onPreferenceChange(WidthPreferenceKey.self, perform: { _ in
if self.text.count != self.previousText.count && self.contentViewWidth < self.w {
} else {
self.leftMost = false
}
self.previousText = self.text
}).modifier(self.makeSlidingEffect().ignoredByLayout()).animation(repeated, value: self.leftMost).clipped(antialiased: true).offset(y: -8)//Text with animation
}
else {
Text(self.text).font(.system(size: 24)).lineLimit(1).foregroundColor(.blue).fixedSize(horizontal: true, vertical: false).background(TextGeometry()).fixedSize(horizontal: false, vertical: true).frame(maxWidth: .infinity, alignment: .center).offset(y: -8)//Text without animation
}
}
}.fixedSize(horizontal: false, vertical: true).layoutPriority(1).frame(maxHeight: 50, alignment: .center).clipped()
}
func makeSlidingEffect() -> some GeometryEffect {
return SlidingEffect(
xPosition: self.leftMost ? -self.w : self.w,
yPosition: 0).ignoredByLayout()
}
}
struct MarqueeText_Previews: PreviewProvider {
#State static var myCoolText = "myCoolText"
static var previews: some View {
MarqueeText(text: $myCoolText)
}
}
struct SlidingEffect: GeometryEffect {
var xPosition: CGFloat = 0
var yPosition: CGFloat = 0
var animatableData: CGFloat {
get { return xPosition }
set { xPosition = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
let pt = CGPoint(
x: xPosition,
y: yPosition)
return ProjectionTransform(CGAffineTransform(translationX: pt.x, y: pt.y)).inverted()
}
}
struct TextGeometry: View {
var body: some View {
GeometryReader { geometry in
return Rectangle().fill(Color.clear).preference(key: WidthPreferenceKey.self, value: geometry.size.width)
}
}
}
struct WidthPreferenceKey: PreferenceKey {
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
typealias Value = CGFloat
}
struct MagicStuff: ViewModifier {
func body(content: Content) -> some View {
Group {
content.alignmentGuide(.underlineLeading) { d in
return d[.leading]
}
}
}
}
extension HorizontalAlignment {
private enum UnderlineLeading: AlignmentID {
static func defaultValue(in d: ViewDimensions) -> CGFloat {
return d[.leading]
}
}
static let underlineLeading = HorizontalAlignment(UnderlineLeading.self)
}
In your existing SwiftUI struct.
(The below sample code will check 3 cases 1.Empty string, 2.Short string that doesn't need to marquee, 3.Lengthy marquee string)
#State var value = ""
#State var counter = 0
var body: some View {
VStack {
Spacer(minLength: 0)
Text("Monday").background(Color.yellow)
HStack {
Spacer()
VStack {
Text("One").background(Color.blue)
}
VStack {
MarqueeText(text: $value).background(Color.red).padding(.horizontal, 8).clipped()
}
VStack {
Text("Two").background(Color.green)
}
Spacer()
}
Text("Tuesday").background(Color.gray)
Spacer(minLength: 0)
Button(action: {
self.counter = self.counter + 1
if (self.counter % 2 == 0) {
self.value = "1Hello World! Hello World! Hello World! Hello World! Hello World!"
} else {
self.value = "1Hello World! Hello"
}
}) {
Text("Button")
}
Spacer()
}
}
Install https://github.com/SwiftUIKit/Marquee 0.2.0 above
with Swift Package Manager and try below code....
struct ContentView: View {
var body: some View {
Marquee {
Text("Hello World!")
.font(.system(size: 40))
}
// This is the key point.
.marqueeWhenNotFit(true)
}
}
When you keep increasing the length of the text until it exceeds the width of the marquee, the marquee animation will automatically start.
I was looking for the same thing, but every solution I tried either did not meet my specifications or caused layout/rendering issues, especially when the text changed or the parent view was refreshed. I ended up just writing something from scratch. It is quite hack-y, but it seems to be working now. I would welcome any suggestions on how it can be improved!
import SwiftUI
struct Marquee: View {
#ObservedObject var controller:MarqueeController
var body: some View {
VStack {
if controller.changing {
Text("")
.font(Font(controller.font))
} else {
if !controller.shouldAnimate {
Text(controller.text)
.font(Font(controller.font))
} else {
AnimatedText(controller: controller)
}
}
}
.onAppear() {
self.controller.checkForAnimation()
}
.onReceive(controller.$text) {_ in
self.controller.checkForAnimation()
}
}
}
struct AnimatedText: View {
#ObservedObject var controller:MarqueeController
var body: some View {
Text(controller.text)
.font(Font(controller.font))
.lineLimit(1)
.fixedSize()
.offset(x: controller.animate ? controller.initialOffset - controller.offset : controller.initialOffset)
.frame(width:controller.maxWidth)
.mask(Rectangle())
}
}
class MarqueeController:ObservableObject {
#Published var text:String
#Published var animate = false
#Published var changing = true
#Published var offset:CGFloat = 0
#Published var initialOffset:CGFloat = 0
var shouldAnimate:Bool {text.widthOfString(usingFont: font) > maxWidth}
let font:UIFont
var maxWidth:CGFloat
var textDoubled = false
let delay:Double
let duration:Double
init(text:String, maxWidth:CGFloat, font:UIFont = UIFont.systemFont(ofSize: 12), delay:Double = 1, duration:Double = 3) {
self.text = text
self.maxWidth = maxWidth
self.font = font
self.delay = delay
self.duration = duration
}
func checkForAnimation() {
if shouldAnimate {
let spacer = " "
if !textDoubled {
self.text += (spacer + self.text)
self.textDoubled = true
}
let textWidth = self.text.widthOfString(usingFont: font)
self.initialOffset = (textWidth - maxWidth) / 2
self.offset = (textWidth + spacer.widthOfString(usingFont: font)) / 2
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.changing = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
withAnimation(Animation.linear(duration:self.duration).delay(self.delay).repeatForever(autoreverses: false)) {
self.animate = self.shouldAnimate
}
}
}
}
}