I was playing around with the animatableData property for a custom Shape I made but I couldn't really visualise what it does and where the system uses it.
I didn't understand how the system knows which animatableData properties it should interpolate when there's a state change. I also didn't understand what the get part of the animatableData property is used for by the system. The only thing I sort of understand is that SwiftUI will update the animatableData property to all the intermediary values between the original and final value for when an #State variable is changed.
If someone can give a very detailed order of events for the use of animatableData by the system I'll be extremely grateful. Make it as detailed as you can because I'm one of those people who feels scratchy even if I'm not understanding 1% of something (however if I do have any question I'll just ask you in the comments).
Thanks in advance!
P.S. I tried returning a constant in the getter for animatableData and my animation still worked perfectly which has confused me even more. Please let me know what the getter is used for if you can.
The simplest answer to your question is to override the default animatableData [inherited by the Animatable protocol] with values used to draw your View. Here's an example of how to do that:
var animatableData: Double {
get { return percent }
set { percent = newValue }
}
Here's an example for you. It:
Draws a Ring on the parent View.
As the value of percent [which you hook up when you define
animatableData] changes, the animation updates the view by drawing a line along the circumference of the defined circle using the percent value at the time of the update.
import SwiftUI
/// This repeats an animation until 5 seconds elapse
struct SimpleAnswer: View {
/// the start/stop sentinel
static var shouldAnimate = true
/// the percentage of the circumference (arc) to draw
#State var percent = 0.0
/// animation duration/delay values
var animationDuration: Double { return 1.0 }
var animationDelay: Double { return 0.2 }
var exitAnimationDuration: Double { return 0.3 }
var finalAnimationDuration: Double { return 1.0 }
var minAnimationInterval: Double { return 0.1 }
var body: some View {
ZStack {
AnimatingOverlay(percent: percent)
.stroke(Color.yellow, lineWidth: 8.0)
.rotationEffect(.degrees(-90))
.aspectRatio(1, contentMode: .fit)
.padding(20)
.onAppear() {
self.performAnimations()
}
.frame(width: 150, height: 150,
alignment: .center)
Spacer()
}
.background(Color.blue)
.edgesIgnoringSafeArea(.all)
}
func performAnimations() {
run()
if SimpleAnswer.shouldAnimate {
restartAnimation()
}
/// Stop the Animation after 5 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: { SimpleAnswer.shouldAnimate = false })
}
func run() {
withAnimation(.easeIn(duration: animationDuration)) {
percent = 1
}
let deadline: DispatchTime = .now() + animationDuration + animationDelay
DispatchQueue.main.asyncAfter(deadline: deadline) {
withAnimation(.easeOut(duration: self.exitAnimationDuration)) {
}
withAnimation(.easeOut(duration: self.minAnimationInterval)) {
}
}
}
func restartAnimation() {
let deadline: DispatchTime = .now() + 2 * animationDuration + finalAnimationDuration
DispatchQueue.main.asyncAfter(deadline: deadline) {
self.percent = 0
self.performAnimations()
}
}
}
/// Draws a Ring on the parent View
/// By default, `Shape` returns the instance of `EmptyAnimatableData` struct as its animatableData.
/// All you have to do is replace this default `EmptyAnimatableData` with a different value.
/// As the value of percent changes, the animation updates the view
struct AnimatingOverlay: Shape {
var percent: Double
func path(in rect: CGRect) -> Path {
let end = percent * 360
var p = Path()
p.addArc(center: CGPoint(x: rect.size.width/2, y: rect.size.width/2),
radius: rect.size.width/2,
startAngle: Angle(degrees: 0),
endAngle: Angle(degrees: end),
clockwise: false)
return p
}
/// This example defines `percent` as the value to animate by
/// overriding the value of `animatableData`
/// inherited as Animatable.animatableData
var animatableData: Double {
get { return percent }
set { percent = newValue }
}
}
#if DEBUG
struct SimpleAnswer_Previews : PreviewProvider {
static var previews: some View {
SimpleAnswer()
}
}
#endif
I found these links to help me answer your question. You should find them useful as well.
Wenderlich - How to Create a Splash Screen With SwiftUI
Majid - The Magic of Animatable Values
Animations in SwiftUI - Majid
Related
I’m after a vertical scrollview that’s infinite both ways: scrolling up to the top or down to the bottom results in more items being added dynamically. Almost all help I’ve encountered is only concerned with the bottom side being infinite in scope. I did come across this relevant answer but it’s not what I’m specifically looking for (it’s adding items automatically based on time duration, and requires interaction with direction buttons to specify which way to scroll). This less relevant answer however has been quite helpful. Based on the suggestion made there, I realised I can keep a record of items visible at any time, and if they happen to be X positions from the top/bottom, to insert an item at the starting/ending index on the list.
One other note is I’m getting the list to start in the middle, so there’s no need to add anything either way unless you’ve moved 50% up/down.
To be clear, this is for a calendar screen that I want the user to be scroll to any time freely.
struct TestInfinityList: View {
#State var visibleItems: Set<Int> = []
#State var items: [Int] = Array(0...20)
var body: some View {
ScrollViewReader { value in
List(items, id: \.self) { item in
VStack {
Text("Item \(item)")
}.id(item)
.onAppear {
self.visibleItems.insert(item)
/// if this is the second item on the list, then time to add with a short delay
/// another item at the top
if items[1] == item {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
withAnimation(.easeIn) {
items.insert(items.first! - 1, at: 0)
}
}
}
}
.onDisappear {
self.visibleItems.remove(item)
}
.frame(height: 300)
}
.onAppear {
value.scrollTo(10, anchor: .top)
}
}
}
}
This is mostly working fine except for a small but important detail. When an item is added from the top, depending on how I’m scrolling down, it can sometimes be jumpy. This is most noticeable towards the end of clip attached.
I tried your code and couldn't fix anything with List OR ScrollView, but it is possible to as a uiscrollview that scrolls infinitly.
1.wrap that uiscrollView in UIViewRepresentable
struct ScrollViewWrapper: UIViewRepresentable {
private let uiScrollView: UIInfiniteScrollView
init<Content: View>(content: Content) {
uiScrollView = UIInfiniteScrollView()
}
init<Content: View>(#ViewBuilder content: () -> Content) {
self.init(content: content())
}
func makeUIView(context: Context) -> UIScrollView {
return uiScrollView
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
}
}
2.this is my whole code for the infinitly scrolling uiscrollview
class UIInfiniteScrollView: UIScrollView {
private enum Placement {
case top
case bottom
}
var months: [Date] {
return Calendar.current.generateDates(inside: Calendar.current.dateInterval(of: .year, for: Date())!, matching: DateComponents(day: 1, hour: 0, minute: 0, second: 0))
}
var visibleViews: [UIView] = []
var container: UIView! = nil
var visibleDates: [Date] = [Date()]
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
//MARK: (*) otherwise can cause a bug of infinite scroll
func setup() {
contentSize = CGSize(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height * 6)
scrollsToTop = false // (*)
showsVerticalScrollIndicator = false
container = UIView(frame: CGRect(x: 0, y: 0, width: contentSize.width, height: contentSize.height))
container.backgroundColor = .purple
addSubview(container)
}
override func layoutSubviews() {
super.layoutSubviews()
recenterIfNecessary()
placeViews(min: bounds.minY, max: bounds.maxY)
}
func recenterIfNecessary() {
let currentOffset = contentOffset
let contentHeight = contentSize.height
let centerOffsetY = (contentHeight - bounds.size.height) / 2.0
let distanceFromCenter = abs(contentOffset.y - centerOffsetY)
if distanceFromCenter > contentHeight / 3.0 {
contentOffset = CGPoint(x: currentOffset.x, y: centerOffsetY)
visibleViews.forEach { v in
v.center = CGPoint(x: v.center.x, y: v.center.y + (centerOffsetY - currentOffset.y))
}
}
}
func placeViews(min: CGFloat, max: CGFloat) {
// first run
if visibleViews.count == 0 {
_ = place(on: .bottom, edge: min)
}
// place on top
var topEdge: CGFloat = visibleViews.first!.frame.minY
while topEdge > min {topEdge = place(on: .top, edge: topEdge)}
// place on bottom
var bottomEdge: CGFloat = visibleViews.last!.frame.maxY
while bottomEdge < max {bottomEdge = place(on: .bottom, edge: bottomEdge)}
// remove invisible items
var last = visibleViews.last
while (last?.frame.minY ?? max) > max {
last?.removeFromSuperview()
visibleViews.removeLast()
visibleDates.removeLast()
last = visibleViews.last
}
var first = visibleViews.first
while (first?.frame.maxY ?? min) < min {
first?.removeFromSuperview()
visibleViews.removeFirst()
visibleDates.removeFirst()
first = visibleViews.first
}
}
//MARK: returns the new edge either biggest or smallest
private func place(on: Placement, edge: CGFloat) -> CGFloat {
switch on {
case .top:
let newDate = Calendar.current.date(byAdding: .month, value: -1, to: visibleDates.first ?? Date())!
let newMonth = makeUIViewMonth(newDate)
visibleViews.insert(newMonth, at: 0)
visibleDates.insert(newDate, at: 0)
container.addSubview(newMonth)
newMonth.frame.origin.y = edge - newMonth.frame.size.height
return newMonth.frame.minY
case .bottom:
let newDate = Calendar.current.date(byAdding: .month, value: 1, to: visibleDates.last ?? Date())!
let newMonth = makeUIViewMonth(newDate)
visibleViews.append(newMonth)
visibleDates.append(newDate)
container.addSubview(newMonth)
newMonth.frame.origin.y = edge
return newMonth.frame.maxY
}
}
func makeUIViewMonth(_ date: Date) -> UIView {
let month = makeSwiftUIMonth(from: date)
let hosting = UIHostingController(rootView: month)
hosting.view.bounds.size = CGSize(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height * 0.55)
hosting.view.clipsToBounds = true
hosting.view.center.x = container.center.x
return hosting.view
}
func makeSwiftUIMonth(from date: Date) -> some View {
return MonthView(month: date) { day in
Text(String(Calendar.current.component(.day, from: day)))
}
}
}
watch that one closely, its pretty much self explanatory, taken from WWDC 2011 idea, you reset the offset to midle of screen when you get close enough to the edge, and it all comes down to tiling your views so they all appear one on top of each other. if you want any clarification for that class please ask in comments.
when you have those 2 figured out, then you glue the SwiftUIView which is also in the class provided. for now the only way for the views to be seen on screen is to specify an explict size for hosting.view, if you figure out how to make the SwiftUIView size the hosting.view, please tell me in the comments, i am looking for an answer for that. hope that code helps someone, if something is wrong please leave a comment.
After poking at your code I believe that this jumpiness that you're seeing is caused by this:
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
withAnimation(.easeIn) {
items.insert(items.first! - 1, at: 0)
}
}
If you remove both and only leave items.insert(items.first! - 1, at: 0) the jumpiness will stop.
I've been banging my head against the wall with this problem for the past two days... Taking away the DispatchQueue like #Ferologics suggested almost works, but you run into a potential problem of an infinite auto-scroll if you pull down too hard. I ended up scrapping the infinite scroller, and using a pulldown-refresh SwiftUIRefresh to load new items from the top. It does the job for now, but I still would love to know how to get true infinite scrolling going up!
import SwiftUI
import SwiftUIRefresh
struct InfiniteChatView: View {
#ObservedObject var viewModel = InfiniteChatViewModel()
var body: some View {
VStack {
Text("Infinite Scroll View Testing...")
Divider()
ScrollViewReader { proxy in
List(viewModel.stagedChats, id: \.id) { chat in
Text(chat.text)
.padding()
.id(chat.id)
.transition(.move(edge: .top))
}
.pullToRefresh(isShowing: $viewModel.chatLoaderShowing, onRefresh: {
withAnimation {
viewModel.insertPriors()
}
viewModel.chatLoaderShowing = false
})
.onAppear {
proxy.scrollTo(viewModel.stagedChats.last!.id, anchor: .bottom)
}
}
}
}
}
And the ViewModel:
class InfiniteChatViewModel: ObservableObject {
#Published var stagedChats = [Chat]()
#Published var chatLoaderShowing = false
var chatRepo: [Chat]
init() {
self.chatRepo = Array(0...1000).map { Chat($0) }
self.stagedChats = Array(chatRepo[500...520])
}
func insertPriors() {
guard let id = stagedChats.first?.id else {
print("first member of stagedChats does not exist")
return
}
guard let firstIndex = self.chatRepo.firstIndex(where: {$0.id == id}) else {
print(chatRepo.count)
print("ID \(id) not found in chatRepo")
return
}
stagedChats.insert(contentsOf: chatRepo[firstIndex-5...firstIndex-1], at: 0)
}
}
struct Chat: Identifiable {
var id: String = UUID().uuidString
var text: String
init(_ number: Int) {
text = "Chat \(number)"
}
}
Intended Feature:
Tap to add circle
Press "next" to create new "frame"
Drag circle to new position
Press "back" to revert circle to previous position
Issue:
As shown above, at the last part when I tap "back", the circle stays at dragged position instead of being reverted as intended.
e.g. When I add a circle to (0,0), create a new frame, drag the circle to a new location (10, 10) and tap "Back", the console prints "Frame: 0, position (0,0)". And when I tap next, it prints "Frame: 1, position (10,10)". And tapping "Back" prints (0,0) again. But the Circle position does not update.
I have tried using a class for the DraggableCircleModel struct and used #Published on its position but that didn't seem to work as well.
I provided my classes below to give some more context. Also this is only my second time posting a question here so any advice to improve my question would be appreciated. Thanks a bunch!
Back and Next buttons
Button(action: {
self.viewModel.goTo(arrangementIndex: self.viewModel.currentIndex - 1)
}) { Text("Back") }
Button(action: {
self.viewModel.goTo(arrangementIndex: self.viewModel.currentIndex + 1)
}) { Text("Next")}
View used to present the circles:
struct DisplayView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
ZStack {
Rectangle()
.overlay(
TappableView { location in
self.viewModel.addCircle(at: location)
})
ForEach(self.viewModel.currentArrangement.circles, id: \.id) { circle in
return DraggableCircleView(viewModel: self.viewModel,
circleIndex: circle.circleIndex,
diameter: 50,
offset: circle.position)
}
}
}
}
Relevant parts of the DraggableCircle View
struct DraggableCircleView: View {
init(viewModel: ViewModel, circleIndex: Int, diameter: CGFloat, offset: CGPoint) {
// Initialize
...
_viewState = /*State<CGSize>*/.init(initialValue: CGSize(width: offset.x, height: offset.y))
// **Debugging print statement**
print("\(self.viewModel.currentCircles.forEach{ print("Frame: \(self.viewModel.currentIndex), position \($0.position)") }) \n")
}
var body: some View {
let minimumLongPressDuration = 0.0
let longPressDrag = LongPressGesture(minimumDuration: minimumLongPressDuration)
.sequenced(before: DragGesture())
.updating($dragState) { value, state, transaction in
// Update circle position during drag
...
}
.onEnded { value in
guard case .second(true, let drag?) = value else { return }
// get updated position of circle after drag
...
self.viewModel.setPositionOfCircle(at: self.circleIndex, to: circlePosition)
}
return Circle()
// Styling omitted
...
.position(
x: viewState.width + dragState.translation.width,
y: viewState.height + dragState.translation.height
)
.gesture(longPressDrag)
}
Solved it. The issue lies with the .position(...) modifier of the DraggableCircle View. Previously, it was only reflecting the state of the DraggableCircle but was not updating based on the underlying ViewModel and Model.
Changing it to:
.position(
x: self.viewModel.currentCircles[circleIndex].position.x + dragState.translation.width,
y: self.viewModel.currentCircles[circleIndex].position.y + dragState.translation.height)
did the trick. This is because the position of the DraggableCircle now reflects the underlying ViewModel instead of just the state of the DraggableCircle.
I would like to smoothly accelerate the rotation speed of a shape in a SwiftUI application then slow it back down again to fixed speed. First I tried toggling the animation speed using a #State Bool as I would any other property (e.g. .speed(speedUp ? 5.0 : 1.0)), but I suppose animation properties themselves are not themselves animatable. I've also tried using an AnimatableModifier to no effect:
import SwiftUI
struct SpeedModifier: AnimatableModifier {
var speed: Double
var animatableData: Double {
get { speed }
set { speed = newValue }
}
func body(content: Content) -> some View {
return content.animation(
Animation
.linear(duration: 5.0)
.speed(speed)
.repeatForever(autoreverses: false)
)
}
}
struct SwiftUIView: View {
#State var isRotating = false
#State var speedUp = false
var body: some View {
Rectangle()
.frame(width: 200, height: 200)
.rotationEffect(.degrees(isRotating ? 360 : 0))
.modifier(SpeedModifier(speed: speedUp ? 5.0 : 1.0))
.onAppear {
self.isRotating.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.speedUp.toggle()
}
}
}
}
struct SwiftUIView_Previews: PreviewProvider {
static var previews: some View {
SwiftUIView()
}
}
You may try .timingCurve animation. In this example rectangle rotated slower and faster all the time:
struct RotatingView: View {
#State private var rotationDegree = 0.0
private var timeCurveAnimation: Animation {
return Animation.timingCurve(0.5, 0.8, 0.8, 0.3, duration: 6)
.repeatForever(autoreverses: false)
}
var body: some View {
Rectangle()
.frame(width: 200, height: 200)
.rotationEffect(.degrees(rotationDegree))
.onAppear() {
withAnimation(self.timeCurveAnimation) {
self.rotationDegree = 720.0
}
}
}
}
Unfortunately there are almost no documentation what does (c0x:c0y:c1x: c1y:) parameters mean, tried some samples from this github.
More complex animations are described in this article, it should be useful
So far as I've been able to determine, being able to dynamically change a running animation is, as of this writing (iOS 13, OSX 10.15), unfortunately a job for Core Animation, where it's reasonably simple.
For example, using Core Animation, we can add the following animation to a layer, causing it to rotate once every 0.5 seconds, indefinitely.
private let animation: CABasicAnimation = {
let animation = CABasicAnimation(keyPath: "transform.rotation")
animation.duration = 0.5
animation.fromValue = 0.0
animation.toValue = 2.0 * -Float.pi
animation.repeatCount = .infinity
return animation
}()
Once you've got that going, then the following extension allows for smooth changing of the layer's speed, and stopping the layer in-situ.
import QuartzCore
extension CALayer {
// Set up our view of the world such that time began from here,
// so that we don't feel the need to change anything when our
// properties are mutated. Handy for things like changing the
// speed without first snapping back to the model state.
func syncTimeToCurrent() {
timeOffset = convertTime(CACurrentMediaTime(), from: nil)
beginTime = CACurrentMediaTime()
}
// Attempt to sync up the model transform with the presentation
// transform. Handy for preventing the presentation from snapping
// back to the model after removing a transform animation.
func syncTransformToPresentation() {
if let presentationTransform = presentation()?.transform {
transform = presentationTransform
}
}
}
And you'd use it like so. Details of how the view is instantiated omitted; in my case, the view is a layer hosting view with 3 image sublayers, two of which are static, and one of which rotates.
final class Rotating: NSView {
private let animation: CABasicAnimation = {
let animation = CABasicAnimation(keyPath: "transform.rotation")
animation.duration = 0.5
animation.fromValue = 0.0
animation.toValue = 2.0 * -Float.pi
animation.repeatCount = .infinity
return animation
}()
private let rotatingLayer: CALayer = { return CALayer() }()
// Our speed is that of our rotating layer; return it to callers when
// requested, and allow them to set it.
var speed: Float {
get { return rotatingLayer.speed }
set {
// Starting rotation from a dead stop is just adding
// the animation to the layer.
func run() {
rotatingLayer.add(animation, forKey: nil)
}
// Key to setting the speed, if we are already rotating,
// is to ensure we don't jump to the start position when
// we do that, so set up the layer's view of time such
// that it'll feel there's nothing to do in that regard.
func set() {
rotatingLayer.syncTimeToCurrent()
}
// Stopping rotation is just removing the transform
// animation, but we ideally want to halt it where it is
// at the moment, rather than having it snap back to the
// original position.
func off() {
rotatingLayer.syncTransformToPresentation()
rotatingLayer.removeAllAnimations()
}
// If we're being asked to set a zero speed, then it's
// likely that the caller knows things that we don't,
// such as that we're about to disappear. Stop rotation,
// so things are in a well-defined state.
//
// If the new speed isn't zero, but our current speed is
// zero, then we need to run.
//
// Otherwise, we need to set the already-running rotation
// to the new speed.
if newValue == .zero { off() }
else if speed == .zero { run() }
else { set() }
rotatingLayer.speed = newValue
}
}
}
So, reasonably simple there, really; all the tools are there to make things dynamically modifiable in a straightforward manner, so you could just do the same and then import the view into SwiftUI, bind it, etc.
I'd love to have someone explain how to accomplish the same thing in pure SwiftUI, without having to drop into Core Animation. At the moment, that's how I do it.
So I have a Rectangle with an added DragGesture and want to track gesture start, change and ending. The issue is when I put another finger on the Rectangle while performing the gesture, the first gesture stop calling onChange handler and does not fire onEnded handler.
Also the handlers doesn't fire for that second finger.
But if I place third finger without removing previous two the handlers for that gesture start to fire (and so on with even presses cancel out the odd ones)
Is it a bug? Is there a way to detect that the first gesture was canceled?
Rectangle()
.fill(Color.purple)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged() { event in
self.debugLabelText = "changed \(event)"
}
.onEnded() { event in
self.debugLabelText = "ended \(event)"
}
)
Thanks to #krjw for the hint with an even number of fingers
This appears to be a problem in the Gesture framework for attempting to detect a bunch of gestures even if we didn't specify that it should be listening for them.
As the documentation is infuriatingly sparse we can only really guess at what the intended behaviour and lifecycle here is meant to be (IMHO - this seems like a bug) - but it can be worked around.
Define a struct method like
func onDragEnded() {
// set state, process the last drag position we saw, etc
}
Then combine several gestures into one to cover the bases that we didn't specify
let drag = DragGesture(minimumDistance: 0)
.onChanged({ drag in
// Do stuff with the drag - maybe record what the value is in case things get lost later on
})
.onEnded({ drag in
self.onDragEnded()
})
let hackyPinch = MagnificationGesture(minimumScaleDelta: 0.0)
.onChanged({ delta in
self.onDragEnded()
})
.onEnded({ delta in
self.onDragEnded()
})
let hackyRotation = RotationGesture(minimumAngleDelta: Angle(degrees: 0.0))
.onChanged({ delta in
self.onDragEnded()
})
.onEnded({ delta in
self.onDragEnded()
})
let hackyPress = LongPressGesture(minimumDuration: 0.0, maximumDistance: 0.0)
.onChanged({ _ in
self.onDragEnded()
})
.onEnded({ delta in
self.onDragEnded()
})
let combinedGesture = drag
.simultaneously(with: hackyPinch)
.simultaneously(with: hackyRotation)
.exclusively(before: hackyPress)
/// The pinch and rotation may not be needed - in my case I don't but
/// obviously this might be very dependent on what you want to achieve
There might be a better combo for simultaneously and exclusively but for my use case at least (which is for something similar to a joystick) this seems like it is doing the job
There is also a GestureMask type that might have done the job but there is no documentation on how that works.
One solution is to use a #GestureState property that tracks if the drag is currently running. The state will be reset to false automatically when the gesture is cancelled.
struct DragSampleView: View {
#GestureState private var dragGestureActive: Bool = false
#State var dragOffset: CGSize = .zero
var draggingView: some View {
Text("DRAG ME").padding(50).background(.red)
}
var body: some View {
ZStack {
Color.blue.ignoresSafeArea()
draggingView
.offset(dragOffset)
.gesture(DragGesture()
.updating($dragGestureActive) { value, state, transaction in
state = true
}
.onChanged { value in
print("onChanged")
dragOffset = value.translation
}.onEnded { value in
print("onEnded")
dragOffset = .zero
})
.onChange(of: dragGestureActive) { newIsActiveValue in
if newIsActiveValue == false {
dragCancelled()
}
}
}
}
private func dragCancelled() {
print("dragCancelled")
dragOffset = .zero
}
}
struct DragV_PreviewProvider: PreviewProvider {
static var previews: some View {
DragSampleView()
}
}
See https://developer.apple.com/documentation/swiftui/draggesture/updating(_:body:)
I'm trying to write a view that displays 3 buttons, I cannot get the animation to start on load.
When a button is tapped, I want it to animate until either:
it is tapped a second time
another of the 3 buttons is tapped
I have got the code working using a #Environment object to store the running state. It toggles between the 3 buttons nicely:
The code for this is here:
struct ContentView : View {
#EnvironmentObject var model : ModelClockToggle
var body: some View {
VStack {
ForEach(0...2) { timerButton in
ActivityBreak(myId: timerButton)
.padding()
}
}
}
}
import SwiftUI
struct ActivityBreak : View {
var myId: Int
#EnvironmentObject var model : ModelClockToggle
let anim1 = Animation.basic(duration: 1.0, curve: .easeInOut).repeatCount(Int.max)
let noAni = Animation.basic(duration: 0.2, curve: .easeInOut).repeatCount(0)
var body: some View {
return Circle()
.foregroundColor(.red)
.scaleEffect(self.model.amIRunning(clock: self.myId) ? 1.0 : 0.6)
.animation( self.model.amIRunning(clock: self.myId) ? anim1 : noAni )
.tapAction {
self.model.toggle(clock: self.myId)
}
}
}
For completeness, the model is:
import Foundation
import SwiftUI
import Combine
class ModelClockToggle: BindableObject {
let didChange = PassthroughSubject<ModelClockToggle, Never>()
private var clocksOn: [Bool] = [false,false,false]
init() {
clocksOn = []
clocksOn.append(UserDefaults.standard.bool(forKey: "toggle1"))
clocksOn.append(UserDefaults.standard.bool(forKey: "toggle2"))
clocksOn.append(UserDefaults.standard.bool(forKey: "toggle3"))
debugPrint(clocksOn)
}
func toggle(clock: Int) {
debugPrint(#function)
if clocksOn[clock] {
clocksOn[clock].toggle()
} else {
clocksOn = [false,false,false]
clocksOn[clock].toggle()
}
saveState()
didChange.send(self)
}
func amIRunning(clock: Int) -> Bool {
debugPrint(clocksOn)
return clocksOn[clock]
}
private func saveState() {
UserDefaults.standard.set(clocksOn[0], forKey: "toggle1")
UserDefaults.standard.set(clocksOn[1], forKey: "toggle2")
UserDefaults.standard.set(clocksOn[2], forKey: "toggle3")
}
}
How do I make the repeating animation start at load time based on the #Environment object I have passed into the View? Right now SwiftUI only seems to consider state change once the view is loaded.
I tried adding an .onAppear modifier, but that meant I had to use a different animator - which had very strange effects.
help gratefully received.
In your example, you are using an implicit animation. Those are animations that will look for changes on any animatable parameter such as size, position, opacity, color, etc. When SwiftUI detects any change, it will animate it.
In your specific case, Circles are normally scaled to 0.6 while not active, and 1.0 when active. Changes between inactive and active states, make your Circle to alter the scale, and this changes are animated in a loop.
However, your problem is that a Circle that is initially loaded at a 1.0 scale (because the model says it is active), will not detect a change: It starts at 1.0 and remains at 1.0. So there is nothing to animate.
In your comments you mention a solution, that involves having the model postpone loading the state of the Circle states. That way, your view is created first, then you ask the model to load states and then there is a change in your view that can be animated. That works, however, there is a problem with that.
You are making your model's behaviour dependent on the view. When it should really be the other way around. Suppose you have two instances of your view on the screen. Depending on timing, one will start fine, but the other will not.
The way to solve it, is making sure the entire logic is handle by the view itself. What you want to accomplish, is that your Circle always gets created with a scale of 0.6. Then, you check with the model to see if the Circel should be active. If so, you immediately change it to 1.0. This way you guarantee the view's animation.
Here is a possible solution, that uses a #State variable named booted to keep track of this. Your Circles will always be created with a scale of 0.6, but once the onAppear() method is call, the view will scale to 1.0 (if active), producing the corresponding animation.
struct ActivityBreak : View {
var myId: Int
#EnvironmentObject var model : ModelClockToggle
#State private var booted: Bool = false
// Beta 4
let anim1 = Animation.easeInOut(duration: 1.0).repeatCount(Int.max)
let noAni = Animation.easeInOut(duration: 0.2).repeatCount(0)
// Beta 3
// let anim1 = Animation.basic(duration: 1.0, curve: .easeInOut).repeatCount(Int.max)
// let noAni = Animation.basic(duration: 0.2, curve: .easeInOut).repeatCount(0)
var body: some View {
return Circle()
.foregroundColor(.red)
.scaleEffect(!booted ? 0.6 : self.model.amIRunning(clock: self.myId) ? 1.0 : 0.6)
.animation( self.model.amIRunning(clock: self.myId) ? anim1 : noAni )
.tapAction {
self.model.toggle(clock: self.myId)
}
.onAppear {
self.booted = true
}
}
}