I'm new to SwiftUI coming from UIKit. I'm trying to achieve the following:
Given an array of mutable states in a view model that represents current progress to be displayed on a Circle using trim(from:to:)
Loop over each item to render a circle with the current progress and animate changes from the old to the new position
A view model will periodically update the array that is a #Published value in an ObservableObject.
I setup a demo view that looks like this
#StateObject var demoViewModel: DemoViewModel = DemoViewModel()
var body: some View {
ForEach(demoViewModel.progress, id: \.self) { progress in
Circle()
.trim(from: 0, to: progress.progress)
.stroke(style: StrokeStyle(lineWidth: 15, lineCap: .round))
.foregroundColor(.red)
.frame(width: 100)
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 1), value: demoViewModel.progress)
}
}
and a test view model like this
class DemoViewModel: ObservableObject {
#Published var progress: [Demo] = [.init(progress: 0)]
struct Demo: Hashable {
let progress: Double
}
init() {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
let current = self.progress[0]
if current.progress < 1 {
self.progress[0] = .init(progress: current.progress + 0.1)
}
}
}
}
For the sake of the demo the array only has one item. What I expected was on each iteration a new value is written to the item in the array which triggers the animation to animate to that value. What actually happens is the progress updates, but it jumps to each new value for no animation.
Try this approach, where the self.demos[0].progress in init()
is updated rather than creating a new Demo(...) at each time tick.
Note, modified a few other things as well, such as the .animation(...value:..) and in particular the ForEach loop with the added Demo unique id.
struct ContentView: View {
#StateObject var demoViewModel: DemoViewModel = DemoViewModel()
var body: some View {
VStack {
ForEach(demoViewModel.demos) { demo in // <-- here
Circle()
.trim(from: 0, to: demo.progress)
.stroke(style: StrokeStyle(lineWidth: 15, lineCap: .round))
.foregroundColor(.red)
.frame(width: 100)
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 1), value: demo.progress) // <-- here
}
}
}
}
class DemoViewModel: ObservableObject {
#Published var demos:[Demo] = [Demo(progress: 0)]
struct Demo: Identifiable, Hashable { // <-- here
let id = UUID() // <-- here
var progress: Double // <-- here
}
init() {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
if self.demos[0].progress < 1 {
self.demos[0].progress = self.demos[0].progress + 0.1 // <-- here
}
}
}
}
Related
I have modified a custom 5 star rating view (https://swiftuirecipes.com/blog/star-rating-view-in-swiftui) to suite my needs. I use that view in several places in my app to display the current rating for a struct and to change the rating through a selectable list. The problem I have is that when I select a new value for the star rating through the NavigationLink, the underlying rating value changes, but the display does not. I have created a sample app that shows the problem and included it below.
//
// StarTestApp.swift
// StarTest
//
// Created by Ferdinand Rios on 12/20/21.
//
import SwiftUI
#main
struct StarTestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct StarHolder {
var rating: Double = 3.5
}
struct ContentView: View {
#State private var starHolder = StarHolder()
var body: some View {
NavigationView {
NavigationLink {
RatingView(starHolder: $starHolder)
} label: {
HStack {
Text("Rating: \(starHolder.rating, specifier: "%.1f")")
Spacer()
StarRatingView(rating: starHolder.rating, fontSize: 15)
}
.padding()
}
.navigationTitle("Test")
}
}
}
struct RatingView: View {
#Binding var starHolder: StarHolder
var body: some View {
List {
ForEach(0..<11, id: \.self) { index in
HStack {
StarRatingView(rating: Double(index) * 0.5, fontSize: 15)
Spacer()
Image(systemName: starHolder.rating == Double(index) * 0.5 ? "checkmark" : "")
}
.contentShape(Rectangle()) //allows to tap whole area
.onTapGesture {
starHolder.rating = Double(index) * 0.5
}
}
}
.navigationBarTitle(Text("Rating"))
}
}
struct StarRatingView: View {
private static let MAX_RATING: Double = 5 // Defines upper limit of the rating
private let RATING_COLOR = Color(UIColor(red: 1.0, green: 0.714, blue: 0.0, alpha: 1.0))
private let EMPTY_COLOR = Color(UIColor.lightGray)
private let fullCount: Int
private let emptyCount: Int
private let halfFullCount: Int
let rating: Double
let fontSize: Double
init(rating: Double, fontSize: Double) {
self.rating = rating
self.fontSize = fontSize
fullCount = Int(rating)
emptyCount = Int(StarRatingView.MAX_RATING - rating)
halfFullCount = (Double(fullCount + emptyCount) < StarRatingView.MAX_RATING) ? 1 : 0
}
var body: some View {
HStack (spacing: 0.5) {
ForEach(0..<fullCount) { _ in
self.fullStar
}
ForEach(0..<halfFullCount) { _ in
self.halfFullStar
}
ForEach(0..<emptyCount) { _ in
self.emptyStar
}
}
}
private var fullStar: some View {
Image(systemName: "star.fill").foregroundColor(RATING_COLOR)
.font(.system(size: fontSize))
}
private var halfFullStar: some View {
Image(systemName: "star.lefthalf.fill").foregroundColor(RATING_COLOR)
.font(.system(size: fontSize))
}
private var emptyStar: some View {
Image(systemName: "star").foregroundColor(EMPTY_COLOR)
.font(.system(size: fontSize))
}
}
If you run the app, the initial rating will be 3.5 and the stars will show the correct rating. When you select the stars, the RatingView will display with the correct rating checked. Select another rating and return to the ContentView. The text for the rating will update, but the star rating will still be the same as before.
Can anyone point me to what I am doing wrong here? I assume that the StarRatingView would refresh when the starHolder rating changes.
There are a couple of problems here. First, in your RatingView, you are passing a Binding<StarHolder>, but when you update the rating, the struct doesn't show as changed. To fix this, pass in a Binding<Double>, and the change will get noted in ContentView.
The second problem is that StarRatingView can't pick up on the change, so it needs some help. I just stuck an .id(starHolder.rating) onto StarRatingView in ContentView, and that signals to SwiftUI when the StarRatingView has changed so it is updated.
struct ContentView: View {
#State private var starHolder = StarHolder()
var body: some View {
NavigationView {
VStack {
NavigationLink {
RatingView(rating: $starHolder.rating)
} label: {
HStack {
Text("Rating: \(starHolder.rating, specifier: "%.1f")")
Spacer()
StarRatingView(rating: starHolder.rating, fontSize: 15)
.id(starHolder.rating)
}
.padding()
}
.navigationTitle("Test")
}
}
}
}
struct RatingView: View {
#Binding var rating: Double
var body: some View {
List {
ForEach(0..<11, id: \.self) { index in
HStack {
StarRatingView(rating: Double(index) * 0.5, fontSize: 15)
Spacer()
Image(systemName: rating == Double(index) * 0.5 ? "circle.fill" : "circle")
}
.contentShape(Rectangle()) //allows to tap whole area
.onTapGesture {
rating = Double(index) * 0.5
}
}
}
.navigationBarTitle(Text("Rating"))
}
}
One last thing. SwiftUI does not like the "" as an SF Symbol, so I changed the checks to "circle" and "circle.fill". Regardless, you should provide an actual image for both parts of the ternary. Or you could use a "check" and make .opacity() the ternary.
In your StarRatingView change the ForEach(0..<fullCount) {...} etc...
to ForEach(0..<fullCount, id: \.self) {...}.
Same for halfFullCount and emptyCount.
Works well for me.
i have a Shake annimation extension like this,
import SwiftUI
struct WRShake: GeometryEffect {
var amount: CGFloat = 10
var shakesPerUnit = 3
var animatableData: CGFloat
func effectValue(size: CGSize) -> ProjectionTransform {
ProjectionTransform(CGAffineTransform(translationX: amount * sin(animatableData * .pi * CGFloat(shakesPerUnit)), y: 0))
}
}
extension View {
func wrshake(amount: CGFloat = 10, shakeUnits: Int = 4, animatableData: CGFloat) -> some View {
return modifier(WRShake(amount: amount, shakesPerUnit: shakeUnits, animatableData: animatableData))
}
}
then in the contentView, i got this
var body: some Scene {
WindowGroup {
NavigationView {
VStack {
Text("111")
.padding()
.background(Color.blue)
.wrshake(animatableData: self.shakeValue)
// .animation(.easeInOut(duration: 0.5))
.layoutPriority(1)
Button {
withAnimation(.easeInOut(duration: 1)) {
self.shakeValue += 1
}
// self.shakeValue += 1
} label: {
Text("shake")
}
}
}
}
then strange thing is,if i don't apply .animation(.easeInOut(duration: 0.5)) to then Text View, there is no animation.
anyone knows why?
thanks
I think because you are using explicit animations on the :App class and that class doesn't handle explicit animations.
Explicit animations is when you use the withAnimation block and leave the system to figure out how the animate.
And i suppose in #main class you did put the #State var shakeValue: CGFloat = 0 but becasuse it's a :App and not a View it doesn't change.
it works adding the .animation(.easeInOut(duration: 0.5)) because it returns a new view that handle the animations like when your extracting the view like ContentView().
(implicit animations are when you apply the modifier .animation(...))
WindowGroup {
NavigationView {
// StartUpView()
ContentView()
}
// .navigationViewStyle(StackNavigationViewStyle())
}
it turns out that, if i wrap everything into ContentView, it works, but why?
I have a model object, which has a published property displayMode, which is updated asynchronously via events from the server.
class RoomState: NSObject, ObservableObject {
public enum DisplayMode: Int {
case modeA = 0
case modeB = 1
case modeC = 2
}
#Published var displayMode = DisplayMode.modeA
func processEventFromServer(newValue: DisplayMode) {
DispatchQueue.main.async {
self.displayMode = newValue
}
}
}
Then, I have a View, which displays this mode by placing some image in a certain location depending on the value.
struct RoomView: View {
#ObservedObject var state: RoomState
var body: some View {
VStack {
...
Image(systemName: "something")
.offset(x: state.displayMode.rawValue * 80, y:0)
}
}
}
This code works fine, but I want to animate the movement when the value changes. If I change the value in the code block inside the View, I can use withAnimation {..} to create an animation effect, but I am not able to figure out how to do it from the model.
This is the answer, thanks to #aheze. With .animation(), this Image view always animates when the state.displayMode changes.
struct RoomView: View {
#ObservedObject var state: RoomState
var body: some View {
VStack {
...
Image(systemName: "something")
.offset(x: state.displayMode.rawValue * 80, y:0)
.animation(.easeInOut)
}
}
}
Swift 5, iOS 13
This code works if I change the second Gesture to say a LONG Press, but leave them both as tap and it never shows the red box? Am I going mad?
import SwiftUI
struct SwiftUIViewQ: View {
#State var swap: Bool = false
var body: some View {
VStack {
if swap {
SquareView(fillColor: Color.red)
.onTapGesture {
self.swap = false
}
} else {
SquareView(fillColor: Color.blue)
.onTapGesture {
self.swap = true
}
}
}
}
}
struct SquareView: View {
#State var fillColor: Color
var body: some View {
Rectangle()
.fill(fillColor)
.frame(width: 128, height: 128)
.onAppear {
print("fillColor \(self.fillColor)")
}
}
}
Oddly if I add an onAppear to the first view, it works... if I than add an onAppear to the second it breaks it again..
var fillColor doesn't need #State, just remove that and it'll work fine
struct SquareView: View {
var fillColor: Color
var body: some View {
Rectangle()
.fill(fillColor)
.frame(width: 128, height: 128)
.onAppear {
print("fillColor \(self.fillColor)")
}
}
}
I've recently encountered an issue in a container View that has a nested list of View items that use a repeatForever animation, that works fine when firstly drew and jumpy after a sibling item is added dynamically.
The list of View is dynamically generated from an ObservableObject property, and its represented here as Loop. It's generated after a computation that takes place in a background thread (AVAudioPlayerNodeImpl.CompletionHandlerQueue).
The Loop View animation has a duration that equals a dynamic duration property value of its passed parameter player. Each Loop has its own values, that may or not be the same in each sibling.
When the first Loop View is created the animation works flawlessly but becomes jumpy after a new item is included in the list. Which means, that the animation works correctly for the tail item (the last item in the list, or the newest member) and the previous wrongly.
From my perspective, it seems related to how SwiftUI is redrawing and there's a gap in my knowledge, that lead to an implementation that causes the animation states to scatter. The question is what is causing this or how to prevent this from happening in the future?
I've minimised the implementation, to improve clarity and focus on the subject.
Let's take a look into the Container View:
import SwiftUI
struct ContentView: View {
#EnvironmentObject var engine: Engine
fileprivate func Loop(duration: Double, play: Bool) -> some View {
ZStack {
Circle()
.stroke(style: StrokeStyle(lineWidth: 10.0))
.foregroundColor(Color.purple)
.opacity(0.3)
.overlay(
Circle()
.trim(
from: 0,
to: play ? 1.0 : 0.0
)
.stroke(
style: StrokeStyle(lineWidth: 10.0,
lineCap: .round,
lineJoin: .round)
)
.animation(
self.audioEngine.isPlaying ?
Animation
.linear(duration: duration)
.repeatForever(autoreverses: false) :
.none
)
.rotationEffect(Angle(degrees: -90))
.foregroundColor(Color.purple)
)
}
.frame(width: 100, height: 100)
.padding()
}
var body: some View {
VStack {
ForEach (0 ..< self.audioEngine.players.count, id: \.self) { index in
HStack {
self.Loop(duration: self.engine.players[index].duration, play: self.engine.players[index].isPlaying)
}
}
}
}
}
In the Body you'll find a ForEach that watches a list of Players, a #Published property from Engine.
Have a look onto the Engine class:
Class Engine: ObservableObject {
#Published var players = []
func record() {
...
}
func stop() {
...
self.recorderCompletionHandler()
}
func recorderCompletionHandler() {
...
let player = self.createPlayer(...)
player.play()
DispatchQueue.main.async {
self.players.append(player)
}
}
func createPlayer() {
...
}
}
Finally, a small video demo to showcase the issue that is worth more than words:
For this particular example, the last item has a duration that is double the duration of the previous two, that have the same duration each. Although the issue happens regardless of this exemplified state.
Would like to mention that the start time or trigger time is the same for all, the .play a method called in sync!
Edited
Another test after following good practices provided by #Ralf Ebert, with a slight change given my requirements, toggle the play state, which unfortunately causes the same issue, so thus far this does seem to be related with some principle in SwiftUI that is worth learning.
A modified version for the version kindly provided by #Ralf Ebert:
// SwiftUIPlayground
import SwiftUI
struct PlayerLoopView: View {
#ObservedObject var player: MyPlayer
var body: some View {
ZStack {
Circle()
.stroke(style: StrokeStyle(lineWidth: 10.0))
.foregroundColor(Color.purple)
.opacity(0.3)
.overlay(
Circle()
.trim(
from: 0,
to: player.isPlaying ? 1.0 : 0.0
)
.stroke(
style: StrokeStyle(lineWidth: 10.0, lineCap: .round, lineJoin: .round)
)
.animation(
player.isPlaying ?
Animation
.linear(duration: player.duration)
.repeatForever(autoreverses: false) :
.none
)
.rotationEffect(Angle(degrees: -90))
.foregroundColor(Color.purple)
)
}
.frame(width: 100, height: 100)
.padding()
}
}
struct PlayersProgressView: View {
#ObservedObject var engine = Engine()
var body: some View {
NavigationView {
VStack {
ForEach(self.engine.players) { player in
HStack {
Text("Player")
PlayerLoopView(player: player)
}
}
}
.navigationBarItems(trailing:
VStack {
Button("Add Player") {
self.engine.addPlayer()
}
Button("Play All") {
self.engine.playAll()
}
Button("Stop All") {
self.engine.stopAll()
}
}.padding()
)
}
}
}
class MyPlayer: ObservableObject, Identifiable {
var id = UUID()
#Published var isPlaying: Bool = false
var duration: Double = 1
func play() {
self.isPlaying = true
}
func stop() {
self.isPlaying = false
}
}
class Engine: ObservableObject {
#Published var players = [MyPlayer]()
func addPlayer() {
let player = MyPlayer()
players.append(player)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
player.isPlaying = true
}
}
func stopAll() {
self.players.forEach { $0.stop() }
}
func playAll() {
self.players.forEach { $0.play() }
}
}
struct PlayersProgressView_Previews: PreviewProvider {
static var previews: some View {
PlayersProgressView()
}
}
The following demo was created by following the steps (the demo only shows after the stop all to keep it under 2mb maximum image upload in Stack Overflow):
- Add player
- Add player
- Add player
- Stop All (*the animations played well this far)
- Play All (*same issue as previously documented)
- Add player (*the tail player animation works fine)
Found an article reporting a similar issue:
https://horberg.nu/2019/10/15/a-story-about-unstoppable-animations-in-swiftui/
I'll have to find a different approach instead of using .repeatForever
You need to make sure that no view update (triggered f.e. by a change like adding a new player) causes 'Loop' to be re-evaluated again because this could reset the animation.
In this example, I would:
make the player Identifiable so SwiftUI can keep track of the objects (var id = UUID() suffices), then you can use ForEach(self.engine.players) and SwiftUI can keep track of the Player -> View association.
make the player itself an ObservableObject and create a PlayerLoopView instead of the Loop function in your example:
struct PlayerLoopView: View {
#ObservedObject var player: Player
var body: some View {
ZStack {
Circle()
// ...
}
}
That's imho the most reliable way to prevent state updates to mess with your animation.
See here for a runnable example: https://github.com/ralfebert/SwiftUIPlayground/blob/master/SwiftUIPlayground/Views/PlayersProgressView.swift
This problem seems to be generated with the original implementation, where the .animation method takes a conditional and that's what causes the jumpiness.
If we decide not and instead keep the desired Animation declaration and only toggle the animation duration it works fine!
As follows:
ZStack {
Circle()
.stroke(style: StrokeStyle(lineWidth: 10.0))
.foregroundColor(Color.purple)
.opacity(0.3)
Circle()
.trim(
from: 0,
to: player.isPlaying ? 1.0 : 0.0
)
.stroke(
style: StrokeStyle(lineWidth: 10.0, lineCap: .round, lineJoin: .round)
)
.animation(
Animation
.linear(duration: player.isPlaying ? player.duration : 0.0)
.repeatForever(autoreverses: false)
)
.rotationEffect(Angle(degrees: -90))
.foregroundColor(Color.purple)
}
.frame(width: 100, height: 100)
.padding()
Obs: The third element duration is 4x longer, just for testing
The result as desired: