In SwiftUI on WatchOS, how can I animate the width of a Rectangle (or any View for that matter) so that it starts at a certain value and over a specified time animates to a different value?
Specifically, I want to animate a Rectangle to indicate the time left to the next full minute or the next 30 seconds after a minute.
All the examples I've seen are based on Timer.scheduledTimer firing at relatively high speed and setting a #State variable, but my understanding is that especially on WatchOS this should be avoided. Is there a better way?
This is the timer/state based code I have but I feel like there should be a more efficient way:
import SwiftUI
func percentage() -> CGFloat {
1 - CGFloat(fmod(Date().timeIntervalSince1970, 30) / 30)
}
struct ContentView: View {
#State var ratio: CGFloat = percentage()
let timer = Timer.publish(every: 1 / 60, on:.main, in:.common).autoconnect()
var body: some View {
GeometryReader { geometry in
ZStack {
Rectangle()
.foregroundColor(Color.gray)
.frame(width:geometry.size.width, height:5)
HStack {
Rectangle()
.foregroundColor(Color.red)
.frame(width:geometry.size.width * self.ratio, height:5)
Spacer()
}
}
}.onReceive(self.timer) { _ in
self.ratio = percentage()
}
}
}
I think a "more efficient way" to use animation:
struct AnimationRectangle: View {
struct AnimationRectangle: View {
#State private var percentage: CGFloat = 0.0
// count, how much time left to nearest 30 seconds
#State private var animationDuration = 30 - Double(fmod(Date().timeIntervalSince1970, 30))
private var repeatedAnimationFor30Seconds: Animation {
return Animation.easeInOut(duration: 30)
.repeatForever(autoreverses: false)
}
var body: some View {
VStack {
// just showing duration of current animation
Text("\(self.animationDuration)")
ZStack {
Rectangle()
.foregroundColor(.gray)
GeometryReader { geometry in
HStack {
Rectangle()
.foregroundColor(.green)
.frame(width: geometry.size.width * self.percentage)
Spacer()
}
}
}
.frame(height: 5)
.onAppear() {
// first animation without repeating
withAnimation(Animation.easeInOut(duration: self.animationDuration)) {
self.percentage = 1.0
}
// other repeated animations
DispatchQueue.main.asyncAfter(deadline: .now() + self.animationDuration) {
self.percentage = 0.0
self.animationDuration = 30.0
withAnimation(self.repeatedAnimationFor30Seconds) {
self.percentage = 1.0
}
}
}
}
}
}
struct AnimationRectangle_Previews: PreviewProvider {
static var previews: some View {
AnimationRectangle()
}
}
Related
So in the past I have been tracking the scroll position using a scroll view but I've fallen into a situation where I need to track the position using a List. I am using a List because I want some of the built in real estate to create my views such as the default List styles.
I can get the value using PreferenceKeys, but the issue is when I scroll to far upwards, the PreferenceKey value will default back to its position 0, breaking my show shy header view logic.
This is the TrackableListView code
struct TrackableListView<Content: View>: View {
let offsetChanged: (CGPoint) -> Void
let content: Content
init(offsetChanged: #escaping (CGPoint) -> Void = { _ in }, #ViewBuilder content: () -> Content) {
self.offsetChanged = offsetChanged
self.content = content()
}
var body: some View {
List {
GeometryReader { geometry in
Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("ListView")).origin)
}
.frame(width: 0, height: 0)
content
.offset(y: -10)
}
.coordinateSpace(name: "ListView")
.onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: offsetChanged)
}
}
private struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { }
}
And this is my ContentView:
struct ContentView: View {
#State private var contentOffset = CGFloat(0)
#State private var offsetPositionValue: CGFloat = 0
#State private var isShyHeaderVisible = false
var body: some View {
NavigationView {
ZStack {
TrackableListView { offset in
withAnimation {
contentOffset = offset.y
}
} content: {
Text("\(contentOffset)")
}
.overlay(
ZStack {
HStack {
Text("Total points")
.foregroundColor(.white)
.lineLimit(1)
Spacer()
Text("20,000 pts")
.foregroundColor(.white)
.padding(.leading, 50)
}
.padding(.horizontal)
.padding(.vertical, 8)
.frame(width: UIScreen.main.bounds.width)
.background(Color.green)
.offset(y: contentOffset < 50 ? 0 : -5)
.opacity(contentOffset < 50 ? 1 : 0)
.transition(.move(edge: .top))
}
.frame(maxHeight: .infinity, alignment: .top)
)
}
.navigationTitle("Hello")
.navigationViewStyle(StackNavigationViewStyle())
.navigationBarTitleDisplayMode(.inline)
.frame(maxHeight: .infinity, alignment: .top)
.background(AccountBackground())
}
}
}
The issue was that Other Views in my hierarchy (probably introduced by SwiftUI) could be sending the default value, which is why you start getting zero sometimes.
What I needed was a way to determine when to use or forward the new value, versus when to ignore it.
To do this I had to make my value optional GCPoint, with a nil default value, then in your reduce method when using PreferenceKeys you have to do:
if let nextValue = nextValue() {
value = nextValue
}
Then make sure your CGPoint is an optional value.
It's easy to have an animation begin when a view appears, by using .onAppear(). But I'd like to perform a repeating animation whenever one of the view's non-State variables changes. An example:
Here is a view that I would like to "throb" whenever its throbbing parameter, set externally, is true:
struct MyCircle: View {
var throbbing: Bool
#State var scale = 1.0
var body: some View {
Circle()
.frame(width: 100 * scale, height: 100 * scale)
.foregroundColor(.blue)
.animation(.easeInOut.repeatForever(), value: scale)
.onAppear { scale = 1.2 }
}
}
Currently the code begins throbbing immediately, regardless of the throbbing variable.
But imagine this scenario:
struct ContentView: View {
#State var throb: Bool = false
var body: some View {
VStack {
Button("Throb: \(throb ? "ON" : "OFF")") { throb.toggle() }
MyCircle(throbbing: throb)
}
}
}
It looks like this:
Any ideas how I can modify MyCircle so that the throbbing starts when the button is tapped and ends when it is tapped again?
You can use onChange to watch throbbing and then assign an animation. If true, add a repeating animation, and if false, just animate back to the original scale size:
struct MyCircle: View {
var throbbing: Bool
#State private var scale : CGFloat = 0
var body: some View {
Circle()
.frame(width: 100, height: 100)
.scaleEffect(1 + scale)
.foregroundColor(.blue)
.onChange(of: throbbing) { newValue in
if newValue {
withAnimation(.easeInOut.repeatForever()) {
scale = 0.2
}
} else {
withAnimation {
scale = 0
}
}
}
}
}
There're two interesting things that i've just found when trying to achieve yours target.
Animation will be added when the value changed
Animation that added to the view cannot be removed, and we need to drop the animated view from the view hiearachy by remove its id, new view will be created with zero animations.
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
#State var throbbling = false
#State var circleId = UUID()
var body: some View {
VStack {
Toggle("Throbbling", isOn: $throbbling)
circle.id(circleId)
.scaleEffect(throbbling ? 1.2 : 1.0)
.animation(.easeInOut.repeatForever(), value: throbbling)
.onChange(of: throbbling) { newValue in
if newValue == false {
circleId = UUID()
}
}
}.padding()
}
#ViewBuilder
var circle: some View {
Circle().fill(.green).frame(width: 100, height: 100)
}
}
PlaygroundPage.current.setLiveView(ContentView())
I was messing around with a fun animation in SwiftUI when I ran into a weird problem involving animating changes to SwiftUI's SF symbols. Basically, I want to animate a set of expanding circles that lose opacity as they get farther out. This works fine when I animate the circles using the Circle() shape, but throws a weird error when I use Image(systemName: "circle"). Namely, it throws No symbol named 'circle' found in system symbol set and I get the dreaded "purple" error in Xcode. Why does my animation work with shapes but not with SF symbols?
Animation Code with Shapes:
struct ContentView: View {
let timer = Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()
#State var firstIndex: Int = 0
#State var secondIndex: Int = 10
#State var thirdIndex: Int = 20
#State var fourthIndex: Int = 30
private func changeIndex(index: Int) -> Int {
if index == 40 {
return 0
} else {
return index + 1
}
}
var body: some View {
ZStack {
Circle()
.foregroundColor(.black)
.frame(width: 10, height: 10)
ExpandingCircle(index: firstIndex)
ExpandingCircle(index: secondIndex)
ExpandingCircle(index: thirdIndex)
ExpandingCircle(index: fourthIndex)
}
.onReceive(timer) { time in
withAnimation(.linear(duration: 0.25)) {
self.firstIndex = changeIndex(index: firstIndex)
self.secondIndex = changeIndex(index: secondIndex)
self.thirdIndex = changeIndex(index: thirdIndex)
self.fourthIndex = changeIndex(index: fourthIndex)
}
}
}
}
Where ExpandingCircle is defined as:
struct ExpandingCircle: View {
let index: Int
private func getSize() -> CGFloat {
return CGFloat(index * 2)
}
private func getOpacity() -> CGFloat {
if index == 0 || index == 40 {
return 0
} else {
return CGFloat(1 - (Double(index) * 0.025))
}
}
var body: some View {
Circle()
.strokeBorder(Color.red, lineWidth: 4)
.frame(width: getSize(), height: getSize())
.opacity(getOpacity())
}
}
To replicate the error, swap out ExpandingCircle in ContentView for ExpandingCircleImage:
struct ExpandingCircleImage: View {
let index: Int
private func getSize() -> CGFloat {
return CGFloat(index * 2)
}
private func getOpacity() -> CGFloat {
if index == 0 || index == 40 {
return 0
} else {
return CGFloat(1 - (Double(index) * 0.025))
}
}
var body: some View {
Image(systemName: "circle")
.foregroundColor(.red)
.font(.system(size: getSize()))
.opacity(getOpacity())
}
}
Your ExpandingCircleImage is choking because you can't have a system font of size 0, and you keep trying to feed 0 to your ExpandingCircleImage view. However, in addition to that, you don't need to use a timer to drive the animation. In fact, it makes the animation look weird because a timer is not exact. Next, your ExpandingCircle or ExpandingCircleImage should animate itself and be the complete effect.
The next issue you will encounter when you fix the font size = 0 issue, is that .font(.system(size:)) is not animatable as it is. You need to write an AnimatableModifier for it. That looks like this:
struct AnimatableSfSymbolFontModifier: AnimatableModifier {
var size: CGFloat
var animatableData: CGFloat {
get { size }
set { size = newValue }
}
func body(content: Content) -> some View {
content
.font(.system(size: size))
}
}
extension View {
func animateSfSymbol(size: CGFloat) -> some View {
self.modifier(AnimatableSfSymbolFontModifier(size: size))
}
}
The animatableData variable is the key. It teaches SwiftUI what to change to render the animation. In this case, we are animating the size of the font. The view extension is just a convenience so we can use . notation.
Another trick to animating a view like this is to have multiple animations that only go part of the way of the whole. In other words, if you use four circles, the first goes to 25%, the next from 25% to 50%, then 50% to 75%, lastly 75% to 100%. You also appear to have wanted the rings to fade as the expand, so I wrote that in as well. The code below will have two animating views, one made with a shape, and one with an SF Symbol.
struct ContentView: View {
var body: some View {
VStack {
Spacer()
ZStack {
Circle()
.foregroundColor(.black)
.frame(width: 10, height: 10)
ExpandingCircle(maxSize: 100)
}
.frame(height: 100)
Spacer()
ZStack {
Circle()
.foregroundColor(.black)
.frame(width: 10, height: 10)
ExpandingCircleImage(maxSize: 100)
}
.frame(height: 100)
Spacer()
}
}
}
struct ExpandingCircle: View {
let maxSize: CGFloat
#State private var animate = false
var body: some View {
ZStack {
Circle()
.strokeBorder(Color.red, lineWidth: 8)
.opacity(animate ? 0.75 : 1)
.scaleEffect(animate ? 0.25 : 0)
Circle()
.strokeBorder(Color.red, lineWidth: 8)
.opacity(animate ? 0.5 : 0.75)
.scaleEffect(animate ? 0.5 : 0.25)
Circle()
.strokeBorder(Color.red, lineWidth: 8)
.opacity(animate ? 0.25 : 0.5)
.scaleEffect(animate ? 0.75 : 0.5)
Circle()
.strokeBorder(Color.red, lineWidth: 8)
.opacity(animate ? 0 : 0.25)
.scaleEffect(animate ? 1 : 0.75)
}
.frame(width: maxSize, height: maxSize)
.onAppear {
withAnimation(.linear(duration: 4).repeatForever(autoreverses: false)) {
animate = true
}
}
}
}
struct ExpandingCircleImage: View {
let maxSize: CGFloat
#State private var animate = false
var body: some View {
ZStack {
Image(systemName: "circle")
.animateSfSymbol(size: animate ? (maxSize * 0.25) : 1)
.opacity(animate ? 0.75 : 1)
Image(systemName: "circle")
.animateSfSymbol(size: animate ? (maxSize * 0.5) : (maxSize * 0.25))
.opacity(animate ? 0.5 : 0.75)
Image(systemName: "circle")
.animateSfSymbol(size: animate ? (maxSize * 0.75) : (maxSize * 0.5))
.opacity(animate ? 0.25 : 0.5)
Image(systemName: "circle")
.animateSfSymbol(size: animate ? (maxSize) : (maxSize * 0.75))
.opacity(animate ? 0 : 0.25)
}
.foregroundColor(.red)
.onAppear {
withAnimation(.linear(duration: 4).repeatForever(autoreverses: false)) {
animate = true
}
}
}
}
Remember to include the AnimatableModifier in your code.
I want a navigation bar to stick down to the scrollview when scrolling up beyond the "regular scrollview". I use .offset() and GeometryReader for that and it's working. However, the navigation bar noticeably lags behind: Video.
Is there another approach to achieving the sticky navigation bar or something that can be changed about this one? Am I using too many views?
struct V_Home: View {
var previewData = PreviewData()
#State var size: CGRect = .zero
var body: some View {
GeometryReader { geometry in
ZStack {
ScrollView {
VStack {
// used to read the scroll position
GeometryReader { proxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: proxy.frame(in: .named("scrollView")))
}
.frame(height: 0)
.onPreferenceChange(SizePreferenceKey.self) { preferences in
self.size = preferences
}
// List
ForEach(previewData.ScoreSessionList) { scoreSession in
NavigationLink(destination: V_SessionDetail(scoreSession: scoreSession)) {
HStack(spacing: 0) {
V_ScoreSessionListItem(scoreSession: scoreSession)
}
}.padding(.top, 10)
}
.padding([.leading, .trailing], 25)
}
}
.coordinateSpace(name: "scrollView")
// NavBar
VStack {
// This Rectangle is offset to match the scroll position
// Is is lagging behind noticably
Rectangle()
.fill(Color(.green))
.frame(height: 80)
.offset(y: self.size.minY > 0 ? self.size.minY : 0)
.padding(0)
Spacer()
}
}
.edgesIgnoringSafeArea(.all)
.navigationBarTitle("")
.navigationBarHidden(true)
}
}
}
// used to make scrollview position accessible to other view
struct SizePreferenceKey: PreferenceKey {
typealias Value = CGRect
static var defaultValue: Value = .zero
static func reduce(value _: inout Value, nextValue: () -> Value) {
_ = nextValue()
}
}
I've got an animated Image which is sliding up and down, using the offset and a timer. This works totally fine until you combine a GeometryReader and a NavigationView. For both, NavView and GeoReader on their own, the animation is working as well. Any solutions? (I know, in this example the GeometryReader is not needed)
struct TestView: View{
#State var offsetSwipeUp: CGFloat = 0
var body: some View{
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
return NavigationView {
GeometryReader { geometry in
Image(systemName: "chevron.up")
.animation(.easeInOut(duration: 1))
.onReceive(timer){ _ in
if self.offsetSwipeUp == .zero{
self.offsetSwipeUp = -10
} else {
self.offsetSwipeUp = .zero
}
}
.offset(y: CGFloat(self.offsetSwipeUp))
.navigationBarTitle("")
.navigationBarHidden(true)
}
}
}
}
In this case order of modifiers looks important.
The below variant works. Tested with Xcode 11.4 / iOS 13.4
struct TestView: View {
#State var offsetSwipeUp: CGFloat = 0
var body: some View{
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
return NavigationView {
GeometryReader { geometry in
Image(systemName: "chevron.up")
.animation(.easeInOut(duration: 1))
.offset(y: CGFloat(self.offsetSwipeUp))
.navigationBarTitle("")
.navigationBarHidden(true)
}
}
.onReceive(timer){ _ in
if self.offsetSwipeUp == .zero{
self.offsetSwipeUp = -10
} else {
self.offsetSwipeUp = .zero
}
}
}
}