Multiple animations interrupt/interfere with eachother - swift

I attempted to apply multiple animations to an object, one that loops on a short timer and one that changes on a button press. After reducing it down to an example, it appears that multiple animations interfere with eachother (replacing all animations duration/repeatability with the most recently triggered.
Example code:
import SwiftUI
struct RectangleView: Shape {
var height: CGFloat
var width: CGFloat
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get {
AnimatablePair(height, width)
}
set {
height = newValue.first
width = newValue.second
}
}
func path(in rect: CGRect) -> Path {
var path = Path()
var rect = CGRect()
rect.size.width = self.width
rect.size.height = self.height
path.addRect(rect)
return path
}
}
struct ContentView: View {
#State var width: CGFloat = 100
#State var height: CGFloat = 100
var body: some View {
VStack {
RectangleView(height: height, width: width)
.animation(.linear(duration: 30).repeatForever(), value: height)
.animation(.spring(response: 1, dampingFraction: 0.5, blendDuration: 1), value: width)
.onAppear(perform: {
height = 200
})
Button("Add width via spring", action: {
width += 10
})
}
.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Expected: Rectangle continues to grow vertically linearly over 30 seconds after clicking 'add width' button.
Actual: Rectangle springs vertically over 1 second along with the width.

Related

SwiftUI set max height on ScrollView

I have built a Chips Container based on this link. Basically is just a container that orders chips in rows. This is my code:
struct ChipsContent: View {
#ObservedObject var viewModel = ChipsViewModel()
#State private var totalHeight
= CGFloat.zero
var body: some View {
var width = CGFloat.zero
var height = CGFloat.zero
return ScrollView {
GeometryReader { geo in
ZStack(alignment: .topLeading, content: {
ForEach(viewModel.dataObject) { chipsData in
Chips(systemImage: chipsData.systemImage,
titleKey: chipsData.titleKey,
isSelected: chipsData.isSelected)
.padding(.all, 5)
.alignmentGuide(.leading) { dimension in
if (abs(width - dimension.width) > geo.size.width) {
width = 0
height -= dimension.height
}
let result = width
if chipsData.id == viewModel.dataObject.last!.id {
width = 0
} else {
width -= dimension.width
}
return result
}
.alignmentGuide(.top) { dimension in
let result = height
if chipsData.id == viewModel.dataObject.last!.id {
height = 0
}
return result
}
}
}).background(viewHeightReader($totalHeight))
}.padding(.all, 5)
}.frame(height: height)
}
private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
return GeometryReader { geometry -> Color in
let rect = geometry.frame(in: .local)
DispatchQueue.main.async {
binding.wrappedValue = rect.size.height
}
return .clear
}
}
}
With this code, the container gets the height according to its content. I want to set a max-height to the Scroll view and if the content is greater than this max height the content should be scrollable. Is it possible to do that?
Use .frame(minHeight: …, maxHeight: …)
By setting minimum and maximum heights you are limiting the freedom of SwiftUI to decide about the size, so you have more control versus just mentioning .frame(height: …), which is treated as a recommendation but not a value that must be adhered to.

Switching a #State property to a #Binding property interferes with animation

I am currently building a bottom sheet UI like Apple Map's and I encountered myself with some weird bug that I am struggling to fix.
The following snippet represents the bottom sheet and includes a ready-to-go preview. You can try it out and see that everything works as expected in both the simulator and in XCode's preview engine.
import SwiftUI
import MapKit
struct ReleaseGesture<Header: View, Content: View>: View {
// MARK: Init properties
// Height of the provided header view
let headerHeight: CGFloat
// Height of the provided content view
let contentHeight: CGFloat
// The spacing between the header and the content
let separation: CGFloat
let header: () -> Header
let content: () -> Content
// MARK: State
#State private var opened = false
#GestureState private var translation: CGFloat = 0
// MARK: Constants
let capsuleHeight: CGFloat = 5
let capsulePadding: CGFloat = 5
// MARK: Computed properties
// The current static value that is always taken into account to compute the sheet's position
private var offset: CGFloat {
self.opened ? self.headerHeight + self.contentHeight : self.headerHeight
}
// Gesture used for the snap animation
private var gesture: some Gesture {
DragGesture()
.updating(self.$translation) { value, state, transaction in
state = -value.translation.height
}
.onEnded {_ in
self.opened.toggle()
}
}
// Animation used when the drag stops
private var animation: Animation {
.spring(response: 0.3, dampingFraction: 0.75, blendDuration: 1.5)
}
// Drag indicator used to indicate the user can drag the sheet
private var dragIndicator: some View {
Capsule()
.fill(Color.gray.opacity(0.4))
.frame(width: 40, height: capsuleHeight)
.padding(.vertical, self.capsulePadding)
}
var body: some View {
GeometryReader { reader in
VStack(spacing: 0) {
VStack(spacing: 0) {
self.dragIndicator
VStack(content: header)
.padding(.bottom, self.separation)
VStack(content: content)
}
.padding(.horizontal, 10)
}
// Frame is three times the height to avoid showing the bottom part of the sheet if the user scrolls a lot when the total height turns out to be the maximum height of the screen and is also opened.
.frame(width: reader.size.width, height: reader.size.height * 3, alignment: .top)
.background(Color.white.opacity(0.8))
.cornerRadius(10)
.offset(y: reader.size.height - max(self.translation + self.offset, 0))
.animation(self.animation, value: self.offset)
.gesture(self.gesture)
}
.clipped()
}
// MARK: Initializer
init(
headerHeight: CGFloat,
contentHeight: CGFloat,
separation: CGFloat,
#ViewBuilder header: #escaping () -> Header,
#ViewBuilder content: #escaping () -> Content
) {
self.headerHeight = headerHeight + self.capsuleHeight + self.capsulePadding * 2 + separation
self.contentHeight = contentHeight
self.separation = separation
self.header = header
self.content = content
}
}
struct ReleaseGesture_Previews: PreviewProvider {
struct WrapperView: View {
#State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
#State private var opened = false
var body: some View {
ZStack {
Map(coordinateRegion: $region)
ReleaseGesture(
headerHeight: 25,
contentHeight: 300,
separation: 30,
header: {
RoundedRectangle(cornerRadius: 8)
.fill(Color.black.opacity(0.3))
.frame(height: 30)
},
content: {
RoundedRectangle(cornerRadius: 10)
.fill(Color.orange.opacity(0.2))
.frame(width: 300, height: 300)
}
)
}
.ignoresSafeArea()
}
}
static var previews: some View {
WrapperView()
}
}
Now we switch the opened property to be a Binding property so that the parent view can know the state of the bottom sheet.
Here is the code with the changes.
import SwiftUI
import MapKit
struct ReleaseGesture<Header: View, Content: View>: View {
// MARK: Init properties
// Binding property that shares the state of the sheet to the parent view
#Binding private var opened: Bool
// Height of the provided header view
let headerHeight: CGFloat
// Height of the provided content view
let contentHeight: CGFloat
// The spacing between the header and the content
let separation: CGFloat
let header: () -> Header
let content: () -> Content
// MARK: State
#GestureState private var translation: CGFloat = 0
// MARK: Constants
let capsuleHeight: CGFloat = 5
let capsulePadding: CGFloat = 5
// MARK: Computed properties
// The current static value that is always taken into account to compute the sheet's position
private var offset: CGFloat {
self.opened ? self.headerHeight + self.contentHeight : self.headerHeight
}
// Gesture used for the snap animation
private var gesture: some Gesture {
DragGesture()
.updating(self.$translation) { value, state, transaction in
state = -value.translation.height
}
.onEnded {_ in
self.opened.toggle()
}
}
// Animation used when the drag stops
private var animation: Animation {
.spring(response: 0.3, dampingFraction: 0.75, blendDuration: 1.5)
}
// Drag indicator used to indicate the user can drag the sheet
private var dragIndicator: some View {
Capsule()
.fill(Color.gray.opacity(0.4))
.frame(width: 40, height: capsuleHeight)
.padding(.vertical, self.capsulePadding)
}
var body: some View {
GeometryReader { reader in
VStack(spacing: 0) {
VStack(spacing: 0) {
self.dragIndicator
VStack(content: header)
.padding(.bottom, self.separation)
VStack(content: content)
}
.padding(.horizontal, 10)
}
// Frame is three times the height to avoid showing the bottom part of the sheet if the user scrolls a lot when the total height turns out to be the maximum height of the screen and is also opened.
.frame(width: reader.size.width, height: reader.size.height * 3, alignment: .top)
.background(Color.white.opacity(0.8))
.cornerRadius(10)
.offset(y: reader.size.height - max(self.translation + self.offset, 0))
.animation(self.animation, value: self.offset)
.gesture(self.gesture)
}
.clipped()
}
// MARK: Initializer
init(
opened: Binding<Bool>,
headerHeight: CGFloat,
contentHeight: CGFloat,
separation: CGFloat,
#ViewBuilder header: #escaping () -> Header,
#ViewBuilder content: #escaping () -> Content
) {
self._opened = opened
self.headerHeight = headerHeight + self.capsuleHeight + self.capsulePadding * 2 + separation
self.contentHeight = contentHeight
self.separation = separation
self.header = header
self.content = content
}
}
struct ReleaseGesture_Previews: PreviewProvider {
struct WrapperView: View {
#State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
#State private var opened = false
var body: some View {
ZStack {
Map(coordinateRegion: $region)
ReleaseGesture(
opened: self.$opened,
headerHeight: 25,
contentHeight: 300,
separation: 30,
header: {
RoundedRectangle(cornerRadius: 8)
.fill(Color.black.opacity(0.3))
.frame(height: 30)
},
content: {
RoundedRectangle(cornerRadius: 10)
.fill(Color.orange.opacity(0.2))
.frame(width: 300, height: 300)
}
)
}
.ignoresSafeArea()
}
}
static var previews: some View {
WrapperView()
}
}
}
However, if you run the preview in XCode you'll see that when the drag gesture stops, instead of the sheet snapping back to the "opened" state from where we lift the finger off, it does the animation from its initial position.
Weirdly enough, if you try to run this in an iPhone or in the simulator, it behaves as expected, as in the first example. Although I've tried to use the code in a more "complex" app (it has a TabView and some few more things) and the same bug appears.
Any ideas why this is happening?
Here is an example with the example view used in a real app tested in a real iPhone. You'll see that the bug is very noticeable.
Video: https://imgur.com/a/RFZbqbD
Actually I see here some misunderstanding that must be clarified and, probably, then initial solution should be rethink. Binding is not state shared to parent, it is link to parent's state holding source of truth, so your view becomes dependent on parent's capability to refresh it on state change, which is not always reliable (or stable, or persistent, etc.), especially in different view hierarchies (like, sheets, UIKit backend, etc.). Changing binding you do not refresh your view directly (as opposite to changes own state) even if your view depends on value in binding, but change parent state, which might do or might do not update your view back. Finalizing - what you've implied is not reliable approach by nature, and you actually observe this.
Alternate solution: use ObsevableObject/ObservedObject view model pattern.
Tested with Xcode 12.4 / iOS 14.4
import MapKit
class ReleaseGestureVM: ObservableObject {
#Published var opened: Bool = false
}
struct ReleaseGesture<Header: View, Content: View>: View {
// MARK: Init properties
#ObservedObject var vm: ReleaseGestureVM
// Height of the provided header view
let headerHeight: CGFloat
// Height of the provided content view
let contentHeight: CGFloat
// The spacing between the header and the content
let separation: CGFloat
let header: () -> Header
let content: () -> Content
// MARK: State
#GestureState private var translation: CGFloat = 0
// MARK: Constants
let capsuleHeight: CGFloat = 5
let capsulePadding: CGFloat = 5
// MARK: Computed properties
// The current static value that is always taken into account to compute the sheet's position
private var offset: CGFloat {
self.vm.opened ? self.headerHeight + self.contentHeight : self.headerHeight
}
// Gesture used for the snap animation
private var gesture: some Gesture {
DragGesture()
.updating(self.$translation) { value, state, transaction in
state = -value.translation.height
}
.onEnded {_ in
self.vm.opened.toggle()
}
}
// Animation used when the drag stops
private var animation: Animation {
.spring(response: 0.3, dampingFraction: 0.75, blendDuration: 1.5)
}
// Drag indicator used to indicate the user can drag the sheet
private var dragIndicator: some View {
Capsule()
.fill(Color.gray.opacity(0.4))
.frame(width: 40, height: capsuleHeight)
.padding(.vertical, self.capsulePadding)
}
var body: some View {
GeometryReader { reader in
VStack(spacing: 0) {
VStack(spacing: 0) {
self.dragIndicator
VStack(content: header)
.padding(.bottom, self.separation)
VStack(content: content)
}
.padding(.horizontal, 10)
}
// Frame is three times the height to avoid showing the bottom part of the sheet if the user scrolls a lot when the total height turns out to be the maximum height of the screen and is also opened.
.frame(width: reader.size.width, height: reader.size.height * 3, alignment: .top)
.background(Color.white.opacity(0.8))
.cornerRadius(10)
.offset(y: reader.size.height - max(self.translation + self.offset, 0))
.animation(self.animation, value: self.offset)
.gesture(self.gesture)
}
.clipped()
}
// MARK: Initializer
init(
vm: ReleaseGestureVM,
headerHeight: CGFloat,
contentHeight: CGFloat,
separation: CGFloat,
#ViewBuilder header: #escaping () -> Header,
#ViewBuilder content: #escaping () -> Content
) {
self.vm = vm
self.headerHeight = headerHeight + self.capsuleHeight + self.capsulePadding * 2 + separation
self.contentHeight = contentHeight
self.separation = separation
self.header = header
self.content = content
}
}
struct ReleaseGesture_Previews: PreviewProvider {
struct WrapperView: View {
#State private var region = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275), span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5))
#StateObject private var vm = ReleaseGestureVM()
var body: some View {
ZStack {
Map(coordinateRegion: $region)
ReleaseGesture(
vm: self.vm,
headerHeight: 25,
contentHeight: 300,
separation: 30,
header: {
RoundedRectangle(cornerRadius: 8)
.fill(Color.black.opacity(0.3))
.frame(height: 30)
},
content: {
RoundedRectangle(cornerRadius: 10)
.fill(Color.orange.opacity(0.2))
.frame(width: 300, height: 300)
}
)
}
.ignoresSafeArea()
}
}
static var previews: some View {
WrapperView()
}
}

Limit rectangle to screen edge on drag gesture

I'm just getting started with SwiftUI and I was hoping for the best way to tackle the issue of keeping this rectangle in the bounds of a screen during a drag gesture. Right now it goes off the edge until it reaches the middle of the square (I think cause I'm using CGPoint).
I tried doing some math to limit the rectangle and it succeeds on the left side only but it seems like an awful way to go about this and doesn't account for varying screen sizes. Can anyone help?
struct ContentView: View {
#State private var pogPosition = CGPoint()
var body: some View {
PogSound()
.position(pogPosition)
.gesture(
DragGesture()
.onChanged { value in
self.pogPosition = value.location
// Poor solve
if(self.pogPosition.x < 36) {
self.pogPosition.x = 36
}
}
.onEnded { value in
print(value.location)
}
)
}
}
Here is a demo of possible approach (for any view, cause view frame is read dynamically).
Demo & tested with Xcode 12 / iOS 14
struct ViewSizeKey: PreferenceKey {
static var defaultValue = CGSize.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value = nextValue()
}
}
struct ContentView: View {
#State private var pogPosition = CGPoint()
#State private var size = CGSize.zero
var body: some View {
GeometryReader { gp in
PogSound()
.background(GeometryReader {
Color.clear
.preference(key: ViewSizeKey.self, value: $0.frame(in: .local).size)
})
.onPreferenceChange(ViewSizeKey.self) {
self.size = $0
}
.position(pogPosition)
.gesture(
DragGesture()
.onChanged { value in
let rect = gp.frame(in: .local)
.inset(by: UIEdgeInsets(top: size.height / 2.0, left: size.width / 2.0, bottom: size.height / 2.0, right: size.width / 2.0))
if rect.contains(value.location) {
self.pogPosition = value.location
}
}
.onEnded { value in
print(value.location)
}
)
.onAppear {
let rect = gp.frame(in: .local)
self.pogPosition = CGPoint(x: rect.midX, y: rect.midY)
}
}.edgesIgnoringSafeArea(.all)
}
}

How to animate shape via state variable in SwiftUI?

Trying to animate right side of rectangle in SwiftUI on tap. But it's not working(
It has ratio state var and it's of animatable type (CGFloat). Totally out of ideas why. Please advise.
struct ContentView: View {
#State private var animated = false
var body: some View {
Bar(ratio: animated ? 0.0 : 1.0).animation(Animation.easeIn(duration: 1))
.onTapGesture {
self.animated.toggle()
}.foregroundColor(.green)
}
}
struct Bar: Shape {
#State var ratio: CGFloat
var animatableData: CGFloat {
get { return ratio }
set { ratio = newValue }
}
func path(in rect: CGRect) -> Path {
var p = Path()
p.move(to: CGPoint.zero)
let width = rect.size.width * ratio
p.addLine(to: CGPoint(x: width, y: 0))
let height = rect.size.height
p.addLine(to: CGPoint(x: width, y: height))
p.addLine(to: CGPoint(x: 0, y: height))
p.closeSubpath()
return p
}
}
It is not needed #State for animatable data, so fix is simple
struct Bar: Shape {
var ratio: CGFloat
Tested with this demo view (on Xcode 11.2 / iOS 13.2)
struct TestAnimateBar: View {
#State private var animated = false
var body: some View {
VStack {
Bar(ratio: animated ? 0.0 : 1.0).animation(Animation.easeIn(duration: 1))
.foregroundColor(.green)
}
.background(Color.gray)
.frame(height: 40)
.onTapGesture {
self.animated.toggle()
}
}
}

create a #State var area, that is based on two #State var width, an #State var height

How do you combine multiple state variables to form another?
I want to change the value of height OR width by some user interaction, and have everything in the view update accordingly. So the height OR width would change, and the area would change.
I imagine it would look something like this
#State var width: CGFloat = 50.0
#State var height: CGFloat = 100.0
#State var area: CGFloat // somehow equal to width*height
current solution is just calling a func
func area() -> CGFloat {
width * height
}
Don't make area #State; just make it a computed variable:
#State var height: CGFloat = 50.0
#State var width: CGFloat = 100.0
var area: CGFloat {
width * height
}
var body: some View {
VStack {
Text("Width: \(width)")
Text("Height: \(height)")
Text("Area \(area)")
Button(action: {
self.height *= 2
}) {
Text("Double height")
}
Button(action: {
self.width += 10
}) {
Text("Add 10 to width")
}
}
}
I added some code to illustrate that if width or height changes, area will change too, because width or height changing cause the view to be redrawn since they are #State. Since area is computed, when the view is redrawn, area is determined to be the product of the updated width and height values. Doing it as a function like you said in your current solution should also work, though.
If you want area to be #State so that you can pass it to other views as a Binding, do this:
struct ContentView: View {
#State var height: CGFloat = 50.0
#State var width: CGFloat = 100.0
var area: Binding<CGFloat> {
Binding(get: {
self.height * self.width
}) { (newVal) in
}
}
var body: some View {
VStack {
Text("Width: \(width)")
Text("Height: \(height)")
Text("Area \(area.wrappedValue)")
BindingView(num: area)
BindingView(num: $height)
Button(action: {
self.height *= 2
}) {
Text("Double height")
}
Button(action: {
self.width += 10
}) {
Text("Add 10 to width")
}
}
}
struct BindingView: View {
#Binding var num: CGFloat
var body: some View {
Text("Binding number: \(num)")
}
}
I created BindingView as an example of how to use bindings in different ways. For #State variables, you effectively turn them into a Binding by adding the $ prefix, but since area is explicitly Binding, you do not need the $. Also to access the value inside the Binding, you just do the variable .wrappedValue.