How to Animate Binding of Child View - ios

A bit confused as to why this code does not work as expected.
Some generic animatable view:
struct SomeAnimatableView: View, Animatable {
var percent: Double
var body: some View {
GeometryReader { geo in
ZStack {
Rectangle()
.fill(Color.blue)
.frame(height: 120)
Rectangle()
.fill(Color.red)
.frame(width: geo.size.width * CGFloat(self.percent), height: 116)
}
.frame(width: geo.size.width, height: geo.size.height)
}
}
var animatableData: Double {
set { percent = newValue }
get { percent }
}
}
A view which wraps said animatable view:
struct ProgressRectangle: View {
#Binding var percent: Double
var body: some View {
SomeAnimatableView(percent: percent)
}
}
Finally another view, consider it parent:
struct ContentView: View {
#State var percent: Double
var body: some View {
VStack {
ProgressRectangle(percent: $percent.animation())
Text("Percent: \(percent * 100)")
}
.onTapGesture {
self.percent = 1
}
}
}
Issue ⚠️ why doesn't the progress animate? $percent.animation() isn't that supposed cause animations? Lots of questions...
A fix 💡:
.onTapGesture {
withAnimation {
self.percent = 1
}
}
However, this doesn't actually fix the issue or answer the question to why that binding doesn't animate. Because this also seems to animate the entire view, notice the button animating its width in this example:
Thoughts, ideas?

Update: additional clarification
Animation to Binding is a mechanism to transfer animation inside child so when bound variable is changed internally it would be changed with animation.
Thus for considered scenario ProgressRectangle would look like
struct ProgressRectangle: View {
#Binding var percent: Double
var body: some View {
SomeAnimatableView(percent: percent)
.onTapGesture {
withAnimation(_percent.transaction.animation) { // << here !!
self.percent = 0 == self.percent ? 0.8 : 0
}
}
}
}
without onTapGesture in ContentView (similarly as would you have Toggle which changes value internally).
Test module on GitHub
Original (still valid and works)
Here is working solution. Tested with Xcode 11.4 / iOS 13.4
#State var percent: Double = .zero
var body: some View {
VStack {
ProgressRectangle(percent: $percent)
.animation(.default) // << place here !!
.onTapGesture {
self.percent = self.percent == .zero ? 1 : .zero
}
Text("Percent: \(percent * 100)")
}
}

Related

SwiftUI animations are not working inside List

In SwiftUI the animations inside a List are not functioning properly. However, when I replace the List with a ScrollView and a LazyVStack, the animations perform as expected. Is there a solution to fix this? I don't want to switch to LazyVStack because I'm using onMove and onDelete modifiers and some other List-specific stuff.
Environment: Xcode 14.2
struct SomeView: View {
#State var showColor = false
var body: some View {
List {
if showColor {
Color.green
.frame(width: 200, height: 200)
.transition(.scale)
}
Button {
withAnimation {
showColor.toggle()
}
} label: {
Text("show/hide color")
}
}
}
}
Due to the way that the frame is updated for a list item, I am not really sure if there is a way to achieve the desired animation.
Two improvements I can think of, however, would be to add a VStack around the text and the color like so:
struct SomeView: View {
#State var showColor = false
var body: some View {
List {
VStack {
if showColor {
Color.green
.frame(width: 200, height: 200)
.transition(.scale)
}
Button {
withAnimation {
showColor.toggle()
}
} label: {
Text("show/hide color")
}
}
}
}
}
An alternative improvement, which would make the animation smoother, would be to replace the if statement with a "conditional" frame like so.
struct ContentView: View {
#State var showColor = false
var body: some View {
List {
Color.green
.frame(width: showColor ? 200 : 0, height: showColor ? 200 : 0)
.transition(.scale)
Button {
withAnimation {
showColor.toggle()
}
} label: {
Text("show/hide color")
}
}
}
}
By using a combination of both of those, you should be able to receive the best results for your use case. If the animations are still not good enough, I would consider changing the layout or not using a list.

SwiftUI: Init ObservableObject based on Environment

In this example, the blue rectangle should be initially visible on devices with .regular size class and hidden on devices with .compact size class.
I'm using an ObservableObject called Settings and the #Published variable isVisible to manage visibilty of the rectangle. My problem is that I don't know how I can init Settings with the correct horizontalSizeClass from my ContentView. Right now I am using .onAppear to change the value of isVisible but this triggers .onReceive. On compact devices this causes the rectangle to be visible and fading out when the view is presented instead of being invisible right away.
How can I init Settings based on Environment values like horizontalSizeClass so that isVisible is correct from the start?
struct ContentView: View {
#Environment(\.horizontalSizeClass) var horizontalSizeClass
#StateObject var settings = Settings()
#State var opacity: CGFloat = 1
var body: some View {
VStack {
Button("Toggle Visibility") {
settings.isVisible.toggle()
}
.onReceive(settings.$isVisible) { _ in
withAnimation(.linear(duration: 2.0)) {
opacity = settings.isVisible ? 1 : 0
}
}
Rectangle()
.frame(width: 100, height: 100)
.foregroundColor(.blue)
.opacity(opacity)
}
.onAppear {
settings.isVisible = horizontalSizeClass == .regular // too late
}
}
}
class Settings: ObservableObject {
#Published var isVisible: Bool = true // can't get size class here
}
The rectangle should not be visible on start:
We need just perform dependency injection (environment is known is parent so easily can be injected into child), and it is simple to do with internal view, like
struct ContentView: View {
#Environment(\.horizontalSizeClass) var horizontalSizeClass
struct MainView: View {
// later knonw injection
#EnvironmentObject var settings: Settings
var body: some View {
VStack {
Button("Toggle Visibility") {
settings.isVisible.toggle()
}
Rectangle()
.frame(width: 100, height: 100)
.foregroundColor(.blue)
.opacity(settings.isVisible ? 1 : 0) // << direct dependency !!
}
.animation(.linear(duration: 2.0), value: settings.isVisible) // << explicit animation
}
}
var body: some View {
MainView() // << internal view
.environmentObject(
Settings(isVisible: horizontalSizeClass == .regular) // << initial injecttion !!
)
}
}
Tested with Xcode 13.4 / iOS 15.5
Test code is here
withAnimation should be done on the Button action changing the state, e.g.
import SwiftUI
struct RectangleTestView: View {
#Environment(\.horizontalSizeClass) var horizontalSizeClass
#State var settings = Settings()
var body: some View {
VStack {
Button("Toggle Visibility") {
withAnimation(.linear(duration: 2.0)) {
settings.isVisible.toggle()
}
}
Rectangle()
.frame(width: 100, height: 100)
.foregroundColor(.blue)
.opacity(settings.opacity)
}
.onAppear {
settings.isVisible = horizontalSizeClass == .regular
}
}
}
struct Settings {
var isVisible: Bool = true
var opacity: CGFloat {
isVisible ? 1 : 0
}
}
FYI we don't really use onReceive anymore since they added onChange. Also its best to keep view data in structs not move it to expensive objects. "Views are very cheap, we encourage you to make them your primary encapsulation mechanism" Data Essentials in SwiftUI WWDC 2020 at 20:50.

Frame transition animation works conditionally

I am trying to make child view which contains string array moves back and forth with animation as button on parent view is toggled.
However, child view just show up and disappeared without any animation at all.
struct ParentView: View {
#State isToggle: Bool
var body: some View {
ChildView(isToggle: isToggle)
.onTabGesture {
withAnimation {
isToggle.toggle()
}
}
}
}
struct ChildView: View {
let values = [one, two, three, four]
var isToggle: Bool
var body: some View {
HStack {
ForEach(values) { value in
Text("\(value)")
.frame(width: UIScreen.main.bounds.width / 3)
}
}
.frame(width: UIScreen.main.bounds.width, alignment: isToggle ? .trailing : .leading)
}
I changed code(stored property to viewmodel) as below. and It works as expected.
class ViewModel: ObservableObject {
#Published var values = [one, two, three, four]
}
struct ParentView: View {
#State isToggle: Bool
var body: some View {
ChildView(isToggle: isToggle)
.onTabGesture {
withAnimation {
isToggle.toggle()
}
}
}
}
struct ChildView: View {
#EnvironmentObject private vm: ViewModel
var isToggle: Bool
var body: some View {
HStack {
ForEach(values) { vm.value in
Text("\(value)")
.frame(width: UIScreen.main.bounds.width / 3)
}
}
.frame(width: UIScreen.main.bounds.width, alignment: isToggle ? .trailing : .leading)
}
I thought that toggling state redraws view only when with stored property.
But, child view with viewmodel is still redrawn when toggle state changes.
Data itself not changed at all. Please kindly let me know why this is happening.
there are some minor typos in your first code. If I correct them the code runs, and the animation works:
struct ContentView: View {
#State private var isToggle: Bool = false
var body: some View {
ChildView(isToggle: isToggle)
.onTapGesture {
withAnimation {
isToggle.toggle()
}
}
}
}
struct ChildView: View {
let values = ["one", "two", "three", "four"]
var isToggle: Bool
var body: some View {
HStack {
ForEach(values, id: \.self) { value in
Text("\(value)")
.frame(width: UIScreen.main.bounds.width / 3)
}
}
.frame(width: UIScreen.main.bounds.width, alignment: isToggle ? .trailing : .leading)
}
}

SwiftUI drag gesture across multiple subviews

I'm attempting to create a grid of small square views, that when the user hovers over them with their thumb (or swipes across them), the little squares will temporarily "pop up" and shake. Then, if they continue to long press on that view, it would open up another view with more information.
I thought that implementing a drag gesture on the square views would be enough, but it looks like only one view can capture a drag gesture at a time.
Is there way to enable multiple views to capture a drag gesture, or a way to implement a "hover" gesture for iOS?
Here is my main Grid view:
import SwiftUI
struct ContentView: View {
#EnvironmentObject var data: PlayerData
var body: some View {
VStack {
HStack {
PlayerView(player: self.data.players[0])
PlayerView(player: self.data.players[1])
PlayerView(player: self.data.players[2])
}
HStack {
PlayerView(player: self.data.players[3])
PlayerView(player: self.data.players[4])
PlayerView(player: self.data.players[5])
}
HStack {
PlayerView(player: self.data.players[6])
PlayerView(player: self.data.players[7])
PlayerView(player: self.data.players[8])
}
HStack {
PlayerView(player: self.data.players[9])
PlayerView(player: self.data.players[10])
}
}
}
}
And here is my Square view that would hold a small summary to display on the square:
import SwiftUI
struct PlayerView: View {
#State var scaleFactor: CGFloat = 1.0
var player: Player = Player(name: "Phile", color: .green, age: 42)
var body: some View {
ZStack(alignment: .topLeading) {
Rectangle().frame(width: 100, height: 100).foregroundColor(player.color).cornerRadius(15.0).scaleEffect(self.scaleFactor)
VStack {
Text(player.name)
Text("Age: \(player.age)")
}.padding([.top, .leading], 10)
}.gesture(DragGesture().onChanged { _ in
self.scaleFactor = 1.5
}.onEnded {_ in
self.scaleFactor = 1.0
})
}
}
Here is a demo of possible approach... (it is simplified version of your app data settings, but the idea and direction where to evolve should be clear)
The main idea that you capture drag not in item view but in the content view transferring needed states (or calculable dependent data) into item view when (or if) needed.
struct PlayerView: View {
var scaled: Bool = false
var player: Player = Player(name: "Phile", color: .green, age: 42)
var body: some View {
ZStack(alignment: .topLeading) {
Rectangle().frame(width: 100, height: 100).foregroundColor(player.color).cornerRadius(15.0).scaleEffect(scaled ? 1.5 : 1)
VStack {
Text(player.name)
Text("Age: \(player.age)")
}.padding([.top, .leading], 10)
}.zIndex(scaled ? 2 : 1)
}
}
struct ContentView: View {
#EnvironmentObject var data: PlayerData
#GestureState private var location: CGPoint = .zero
#State private var highlighted: Int? = nil
private var Content: some View {
VStack {
HStack {
ForEach(0..<3) { i in
PlayerView(scaled: self.highlighted == i, player: self.data.players[i])
.background(self.rectReader(index: i))
}
}
.zIndex((0..<3).contains(highlighted ?? -1) ? 2 : 1)
HStack {
ForEach(3..<6) { i in
PlayerView(scaled: self.highlighted == i, player: self.data.players[i])
.background(self.rectReader(index: i))
}
}
.zIndex((3..<6).contains(highlighted ?? -1) ? 2 : 1)
}
}
func rectReader(index: Int) -> some View {
return GeometryReader { (geometry) -> AnyView in
if geometry.frame(in: .global).contains(self.location) {
DispatchQueue.main.async {
self.highlighted = index
}
}
return AnyView(Rectangle().fill(Color.clear))
}
}
var body: some View {
Content
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
.updating($location) { (value, state, transaction) in
state = value.location
}.onEnded {_ in
self.highlighted = nil
})
}
}

How make animations work inside ScrollView in SwiftUI? Accordion style animation in SwiftUI

I am trying to build a accordion style view with animations using SwiftUI. But Animations are not working properly if I wrap it into a ScrollView.Below is the code that I am trying to work out.
struct ParentView: View {
#State var isPressed = false
var body: some View {
GeometryReader { geometry in
ScrollView {
Group {
SampleView(index: 1)
SampleView(index: 2)
SampleView(index: 3)
SampleView(index: 4)
}.border(Color.black).frame(maxWidth: .infinity)
}.border(Color.green)
}
}
}
struct SampleView: View {
#State var index: Int
#State var isPressed = false
var body: some View {
Group {
HStack(alignment:.top) {
VStack(alignment: .leading) {
Text("********************")
Text("This View = \(index)")
Text("********************")
}
Spacer()
Button("Press") { self.isPressed.toggle() }
}
if isPressed {
VStack(alignment: .leading) {
Text("********************")
Text("-----> = \(index)")
Text("********************")
}.transition(.slide).animation(.linear).border(Color.red)
}
}
}
}
And Below is the screenshot of problem I'm facing.
To make it animate, wrap self.isPressed.toggle() in a withAnimation closure.
Button("Press") { withAnimation { self.isPressed.toggle() } }

Resources