AnimatableData not working in Shape object with SwiftUI - path

I have this Shape object (Curve) that I'm trying to animate:
private struct Curve : Shape {
private var startAngle : CGFloat
private var endAngle : CGFloat
private var drawn : Bool
private var hAdjustment : CGFloat
var clockwise : Bool = true
init(side: CurveSide, isDrawn: Bool) {
self.startAngle = side.startAngle
self.endAngle = side.endAngle
self.hAdjustment = side == .left ? 25 : -25
self.drawn = isDrawn
}
var animatableData : CGFloat {
get { self.endAngle }
set { self.endAngle = newValue }
}
func path(in rect: CGRect) -> Path {
let rotationAdjusment = CGFloat(90.0)
var baseCirclePath = Path()
baseCirclePath.addArc(center: CGPoint(x: rect.midX + CGFloat(self.hAdjustment), y: rect.midY),
radius: rect.width / 2.3,
startAngle: self.drawn ? Angle(degrees: Double(self.startAngle - rotationAdjusment)) : Angle(degrees: 0),
endAngle: self.drawn ? Angle(degrees: Double(self.endAngle - rotationAdjusment)) : Angle(degrees: 0),
clockwise: !self.clockwise)
return baseCirclePath
}
}
This is my contentView where I'm trying to use that shape (Curve):
HStack {
Spacer()
Curve(side: CurveSide.left, isDrawn: self.leftCurveDrawn)
.stroke(Color.white, lineWidth: 2)
.frame(width: 100)
Curve(side: CurveSide.right, isDrawn: self.rightCurveDrawn)
.stroke(Color.white, lineWidth: 2)
.frame(width:100)
Spacer()
}
And I'm using these variables to trigger the animation:
#State private var rightCurveDrawn = true
#State private var leftCurveDrawn = true
And this is what I get int the screen. The problem is that every time I toggle the #State variables the curves only appear and disappear with no animation. Thank you in advance.

You just need to trim your shapes and specify animation. Tested with Xcode 12 / iOS 14 (on replicated code)
struct ContentView: View {
#State private var rightCurveDrawn = false
#State private var leftCurveDrawn = false
var body: some View {
HStack {
Spacer()
Curve(side: CurveSide.left, isDrawn: self.leftCurveDrawn)
.trim(from: 0, to: leftCurveDrawn ? 1 : 0) // << here !!
.stroke(Color.blue, lineWidth: 2)
.frame(width: 100)
Curve(side: CurveSide.right, isDrawn: self.rightCurveDrawn)
.trim(from: 0, to: rightCurveDrawn ? 1 : 0)
.stroke(Color.red, lineWidth: 2)
.frame(width:100)
Spacer()
}
.animation(.easeInOut(duration: 2)) // << here !!
.onAppear {
self.rightCurveDrawn = true // can be on button or other
self.leftCurveDrawn = true // action
}
}
}

It looks like you will have to implement animation values yourself. What fun!
To get you started, you could add something like this inside your shape:
private var multiplier = 1.0
var animatableData: CGFloat {
get { CGFloat(self.multiplier) }
set { self.multiplier = Double(newValue) }
}
multiplier is nothing special, it's a value added by us, the devs. animatableData however, that's SwiftUI magic. By wrapping your state changes in a withAnimation closure, it will immediately set your shape to the new values, evaluate the final result of animatableData, and then interpolate the values across the animation time.
We can use the multiplier set in animatableData like so:
baseCirclePath.addArc(center: CGPoint(x: rect.midX + CGFloat(self.hAdjustment), y: rect.midY),
radius: rect.width / 2.3,
startAngle: Angle(degrees: Double(self.startAngle - rotationAdjusment) * self.multiplier),
endAngle: Angle(degrees: Double(self.endAngle - rotationAdjusment) * self.multiplier),
clockwise: !self.clockwise)
And don't forget to add this to the end of your init statement, to give SwiftUI something to evaluate:
self.multiplier = self.drawn ? 1 : 0
Note
Please note though, that my simple implementation of animatableData may not be the kind of animation you want here, but you can customize it however you want.
Finally, here's where I learned everything I know about this particular problem.

Related

SwiftUI animation: move up/down on press, spring on release - how to do the "move up" part

How can I get the blue circles to first move away from the green circle before getting back to it?
The animation should be:
press and hold:
the green circle scales down
the blue circles, while scaling down as well, first move "up" (away from their resting position, as if pushed away by the pressure applied) and then down (to touch the green circle again, as if they were pulled back by some gravitational force)
release
everything springs back into place
(bonus) ideally, the blue circles are ejected as the green circle springs up, and they fall back down in their resting position (next to the surface)
I got everything working except the blue circles moving up part.
This is the current animation:
And its playground.
import SwiftUI
import PlaygroundSupport
struct DotsView: View {
var diameter: CGFloat = 200
var size: CGFloat = 25
var isPressed: Bool = false
var body: some View {
ZStack {
ForEach(0...5, id: \.self) { i in
Circle()
.fill(Color.blue)
.frame(width: size, height: size)
.offset(x: 0, y: -(diameter)/2 - size/2)
.rotationEffect(.degrees(CGFloat(i * 60)))
}
}
.frame(width: diameter, height: diameter)
.animation(.none)
.scaleEffect(isPressed ? 0.8 : 1)
.animation(
isPressed ? .easeOut(duration: 0.2) : .interactiveSpring(response: 0.35, dampingFraction: 0.2),
value: isPressed
)
.background(
Circle()
.fill(Color.green)
.scaleEffect(isPressed ? 0.8 : 1)
.animation(isPressed ? .none : .interactiveSpring(response: 0.35, dampingFraction: 0.2), value: isPressed)
)
}
}
struct ContentView: View {
#State private var isPressed: Bool = false
var body: some View {
DotsView(
diameter: 200,
isPressed: isPressed
)
.frame(width: 500, height: 500)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
isPressed = true
}
.onEnded { _ in
isPressed = false
}
)
}
}
let view = ContentView()
PlaygroundPage.current.setLiveView(view)
Thanks
Actually all is needed is to replace linear scaleEffect with custom geometry effect that gives needed scale curve (initially growing then falling).
Here is a demo of possible approach (tested with Xcode 13.4 / iOS 15.5)
Main part:
struct JumpyEffect: GeometryEffect {
let offset: Double
var value: Double
var animatableData: Double {
get { value }
set { value = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
let trans = (value + offset * (pow(5, value - 1/pow(value, 5))))
let transform = CGAffineTransform(translationX: size.width * 0.5, y: size.height * 0.5)
.scaledBy(x: trans, y: trans)
.translatedBy(x: -size.width * 0.5, y: -size.height * 0.5)
return ProjectionTransform(transform)
}
}
and usage
.modifier(JumpyEffect(offset: isPressed ? 0.3 : 0, value: isPressed ? 0.8 : 1))
Complete code on GitHub

Binding variable across two views to control animation speed change

Background
I'm following the excellent tutorial by Jean-Marc Boullianne on animating colour in change in swift. I would like to enhance this example by adding a slider that would allow me to control the animation speed. For that purpose I've defined the relevant UI elements in the ContentView.swift and I would like to use the values in the SplashView.swift that produces the animation.
Problem
The value that I'm attempting to pass via slider is disregarded and the animation keeps reproducing with the same default speed.
Code
ContentView.swift
A note: colours are defined in the assets catalogue for the purpose of example any values will do.
import SwiftUI
struct ContentView: View {
// Color variables for the animation
var colors: [Color] = [Color("AnimationColor1"),
Color("AnimationColor2"),
Color("AnimationColor3"),
Color("AnimationColor4")]
#State var index: Int = 0
#State var progress: CGFloat = 0
#State var animationDuration: Double = 0.5
var body: some View {
VStack {
SplashView(animationType: .leftToRight, color: self.colors[self.index])
.frame(width: 200, height: 100, alignment: .center)
.cornerRadius(10)
.shadow(color: Color("ShadowColor"), radius: 10, x: 0.0, y: 0.0)
Button(action: {
self.index = (self.index + 1) % self.colors.count
}, label: {
Text("Change Colour")
.padding(.top, 20)
})
Slider(value: $animationDuration, in: 0.0...1.0)
Text("Animation duration \(animationDuration)")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
SplashView.swift
I understand that I'm using the #Binding wrong...
//
// SplashView.swift
// ColourChange
//
//
import SwiftUI
/**
Customer observable object
- Parameter color: The color to store
*/
class ColorStore: ObservableObject {
#Published var color: Color
init(color: Color) {
self.color = color
}
}
struct SplashView: View {
// This binds value from the slider option of the swift ui view
#Binding var animationDuration: Double
// Keeping track of the color: animation defaults as per tutorial
#State var layers: [(Color, CGFloat)] = [] // New Color & Progress
var animationType: SplashShape.SplashShapeAnimation
#State private var prevColor: Color // Background colour
#ObservedObject var colorStore: ColorStore
// Those init calls help to deal with the binding problem when the value is called
init(animationType: SplashShape.SplashShapeAnimation,
color: Color,
animationDuration: Binding<Double> = .constant(0.1)) {
self.animationType = animationType
self._prevColor = State<Color>(initialValue: color)
self.colorStore = ColorStore(color: color)
self._animationDuration = animationDuration
}
var body: some View {
// Need to display each layer as an overlay on the Rectangle inside the body variable
Rectangle()
.foregroundColor(self.prevColor) // Current color
// Displaying each layer on top of another
.overlay(
ZStack {
ForEach(layers.indices, id: \.self) { xLayer in
SplashShape(progress: self.layers[xLayer].1, animationType: self.animationType)
.foregroundColor(self.layers[xLayer].0)
}
}
)
.onReceive(self.colorStore.$color, perform: { color in
// Animate color update
self.layers.append((color, 0))
// Exclamation mark in variable calling is necessary to faciliate unwrapping
withAnimation(.easeInOut(duration: self.animationDuration)) {
self.layers[self.layers.count - 1].1 = 1.0
}
})
}
}
SplashShape.swift
For convenience, I have added SplashShape.swift file but that file doesn't differ from the original one available in the tutorial.
import SwiftUI
struct SplashShape: Shape {
public enum SplashShapeAnimation {
case leftToRight
case rightToLeft
}
var progress: CGFloat // Will take values between 0 and 1
var animationType: SplashShapeAnimation
var animatableData: CGFloat {
get { return progress }
set { self.progress = newValue }
}
func path(in rect: CGRect) -> Path {
// We return the correct path after deciding which type of animation is being used
switch animationType {
case .leftToRight:
return leftToRight(rect: rect)
case .rightToLeft:
return rightToLeft(rect: rect)
}
}
func leftToRight(rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: 0, y: 0)) // Top left corner
path.addLine(to: CGPoint(x: rect.width * progress, y: 0)) // Top Right
path.addLine(to: CGPoint(x: rect.width * progress, y: rect.height)) // Bottom right
path.addLine(to: CGPoint(x: 0, y: rect.height)) // Bottom Left
path.closeSubpath()
return path
}
func rightToLeft(rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.width, y: 0))
path.addLine(to: CGPoint(x: rect.width - (rect.width * progress), y: 0))
path.addLine(to: CGPoint(x: rect.width - (rect.width * progress), y: rect.height))
path.addLine(to: CGPoint(x: rect.width, y: rect.height))
path.closeSubpath()
return path
}
}
In your example the animationDuration in SlashView is independent of the ContentView's animationDuration (that is bound to the Slider).
In other words: in your ContentView we do not find the animationDuration in the initialization of SplashView:
SplashView(animationType: .leftToRight, color: self.colors [self.index])
should be:
SplashView(animationType: .leftToRight, color: self.colors [self.index], animationDuration: $animationDuration)
But in reality, if the animationDuration variable is never changed in SplashView, then it shouldn't be declared with #Binding.
In this case you can :
Replace (in SplashView)
#Binding var animationDuration: Double
by
var animationDuration: Double
Change your custom init :
init(animationType: SplashShape.SplashShapeAnimation,
color: Color,
animationDuration: Double = 0.1) {
self.animationType = animationType
self._prevColor = State<Color>(initialValue: color)
self.colorStore = ColorStore(color: color)
self.animationDuration = animationDuration
}
Initialize SplashView(in ContentView), this way :
SplashView(animationType: .leftToRight, color: self.colors[self.index], animationDuration: animationDuration)

SwiftUI Pie Chart: glitches when rotating the device

I'm trying to implement an animated pie chart in SwiftUI.
I noticed that my implementation has some glitches on iPad, specifically when the device is rotated.
As you can see, the pieces are not connected anymore when the device is rotated. Cause: The pieces update their frame size immediately.
I noticed that everything will work fine when drawingGroup() is used with the chart. Another possible approach is to set the frame of the chart explicitly with a .frame modifier (using GeometryReader for the size) and to animate the frame change with .animation(). However, those options are creating problems in the app switcher. When the app is closed and after that, the device is rotated, the preview of the app in the app switcher looks weird. The chart is not centered and the size is off as well:
So that's not an option unfortunately.
Also: The pie chart should have different sizes in portrait vs landscape mode (I simulated that with a padding here). Moreover, I want to be able to animate all pieces of the pie chart individually (the start and end angle) just like in the current implementation.
Hope somebody has an idea how to fix this. Thanks so much for answering!
import SwiftUI
struct ContentView: View {
#ObservedObject var model = CellModel.sharedInstance
var body: some View {
PieChart(dataModel: $model.sample)
//.drawingGroup()
.aspectRatio(1, contentMode: .fit)
.padding()
.padding(.vertical, 200)
}
}
struct PieChart: View {
#Binding var dataModel: [ChartEntry]
var body: some View {
ZStack{
ForEach(dataModel) { elem in
PieChartWrapper(elem: elem)
}
}
}
}
struct PieChartWrapper: View {
#ObservedObject var elem: ChartEntry
var body: some View {
PieChartCell(start: elem.startAngle, end: elem.endAngle)
.foregroundColor(elem.color)
}
}
let chartCoverage: CGFloat = 0.7
struct PieChartCell: Shape {
var start: Double
var end: Double
var animatableData: AnimatablePair<Double, Double> {
get { .init(start, end) }
set {
start = newValue.first
end = newValue.second
}
}
func path(in rect: CGRect) -> Path {
var p = Path()
let center = CGPoint(x: rect.size.width/2, y: rect.size.width/2)
let radius = rect.size.width/2
let startAngle = Angle(degrees: start)
let endAngle = Angle(degrees: end)
let width = radius * chartCoverage
p.addArc(center: center, radius: abs(width - radius), startAngle: startAngle, endAngle: endAngle, clockwise: false)
p.addArc(center: center, radius: radius, startAngle: endAngle, endAngle: startAngle, clockwise: true)
p.closeSubpath()
return p
}
}
class CellModel: ObservableObject {
static let sharedInstance = CellModel()
#Published var sample = [ChartEntry(color: .red), ChartEntry(color: .yellow), ChartEntry(color: .green)] {
didSet{
calcAngles()
}
}
private init() {
calcAngles()
}
private func calcAngles() {
for elem in sample {
withAnimation() {
elem.startAngle = getStartAngle(elem: elem, dataModel: sample)
elem.endAngle = getEndAngle(elem: elem, dataModel: sample)
}
}
}
private func getStartAngle(elem: ChartEntry, dataModel: [ChartEntry]) -> Double {
return 360 * (Double(dataModel.firstIndex(of: elem) ?? 0) / Double(dataModel.count))
}
private func getEndAngle(elem: ChartEntry, dataModel: [ChartEntry]) -> Double {
return 360 * (Double((dataModel.firstIndex(of: elem) ?? 0) + 1) / Double(dataModel.count))
}
}
class ChartEntry: Identifiable, Equatable, ObservableObject{
static func == (lhs: ChartEntry, rhs: ChartEntry) -> Bool {
lhs.id == rhs.id
}
var id = UUID()
var color: Color
#Published var startAngle: Double = 360
#Published var endAngle: Double = 360
init(color: Color) {
self.color = color
}
}

SwiftUI Animated Shape going crazy

First off, my appologies for the unconventional title of this post, but I don't know of any way to describe the behavior any better than that.
To reproduce this problem, create a new project in xCode 12 specifying IOS App with any name and **Interface:**SwiftUI, Life Cycle: SwiftUI App, Language: Swift. Then replace all of Content View with the code listed here.
In either Live Preview, or when running the app, a click on the Poly will trigger the animation. It should move a fraction of a pixel, and do a 1/3 turn (as the angle is in radians).
The problem, as noted on line 37 is that when I try to move or turn the Poly, it goes crazy, multiplying any move by much greater amounts. The color animates fine, but the Animatable properties in the shape do not. The location starts at 200,200 with an angle of 0, and if you try to move it very close, as the sample code does, it overreacts. If you try to move it only a few pixes (say 190,190) it will fly off the screen.
I have not had this happen to any other animations I have done, and have no idea why this is behaving this way.
In trying to debug this, at one point I put print statements on the animatableData getters and setters, and can make no sense of what the animation engine is doing to the variables. It just seems to pick a number that is much further from the source that the value I am asking it to go to.
I am confident that the trig in the path is correct, and suspect the issue lies in one of the following:
My declaration of animatableData somehow
The withAnimation function in the gesture
An issue with animating a CGFloat
SwiftUI is just going crazy
I am running Xcode 12.1 (12A7403) and Swift 5. After many hours of trying to figure this out, I humbly present my problem here. Help me Obiwan Kenobi, you are my only hope...
import SwiftUI
let twoPi:CGFloat = CGFloat.pi * 2
let pi = CGFloat.pi
class Poly: ObservableObject, Identifiable {
#Published var location:CGPoint
#Published var color:Color
var sides:CGFloat
var vertexRadius:CGFloat
var angle:CGFloat
init(at:CGPoint, color:Color, sides:CGFloat, radius:CGFloat, angle:CGFloat=0) {
self.location = at
self.color = color
self.sides = sides
self.vertexRadius = radius
self.angle = angle
}
}
struct ContentView: View {
#ObservedObject var poly:Poly = Poly(at: CGPoint(x:200,y:200), color: .green, sides: 6, radius: 100)
var body: some View {
PolyShape(poly: poly)
.fill(poly.color)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onEnded { gesture in
withAnimation(.easeInOut(duration:15)) {
//This is what doesn't work.
//Try to nudge it a fraction of a pixel, and do only 1/3 of a turn, and it spins and moves much further.
poly.location.x = 200.4
poly.location.y = 200.2
poly.angle = twoPi / 3
poly.color = .red
}
}
)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView(poly: Poly(at: CGPoint(x:200,y:200), color: .blue, sides: 3, radius: 100))
}
}
}
struct PolyShape:Shape {
var poly:Poly
public var animatableData: AnimatablePair<CGFloat, AnimatablePair<CGFloat,CGFloat>> {
get { AnimatablePair(poly.angle, AnimatablePair(poly.location.x, poly.location.y))
}
set {
poly.angle = newValue.first
poly.location.x = newValue.second.first
poly.location.y = newValue.second.second
}
}
func path(in rect: CGRect) -> Path {
var path = Path()
var radial:CGFloat = 0.5
while radial < twoPi + 0.5 {
let radialAngle = twoPi / poly.sides * radial + poly.angle
let newX = poly.location.x + cos(radialAngle) * poly.vertexRadius
let newY = poly.location.y + sin(radialAngle) * poly.vertexRadius
if radial == 0.5 {
path.move(to: CGPoint(x: newX, y: newY))
} else {
path.addLine(to: CGPoint(x: newX, y: newY))
}
radial += 1
}
return path
}
}
You need to separate model from view model, because to have PolyShape correctly work in your case the input data have to be a value.
Here is tested solution (Xcode 12 / iOS 14)
Separate model and view model
class Poly: ObservableObject, Identifiable {
#Published var data:PolyData
init(data: PolyData) {
self.data = data
}
}
struct PolyData {
var location:CGPoint
var color:Color
var sides:CGFloat
var vertexRadius:CGFloat
var angle:CGFloat
init(at:CGPoint, color:Color, sides:CGFloat, radius:CGFloat, angle:CGFloat=0) {
self.location = at
self.color = color
self.sides = sides
self.vertexRadius = radius
self.angle = angle
}
}
Make shape value dependent
struct PolyShape:Shape {
var poly:PolyData // << here !!
// ... other code no changes
}
Update dependent demo
struct ContentView: View {
#ObservedObject var poly:Poly = Poly(data: PolyData(at: CGPoint(x:200,y:200), color: .green, sides: 6, radius: 100))
var body: some View {
PolyShape(poly: poly.data) // << pass data only here !!
.fill(poly.data.color)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onEnded { gesture in
withAnimation(.easeInOut(duration:15)) {
poly.data.location = CGPoint(x: 200.4, y: 200.2)
poly.data.angle = twoPi / 3
poly.data.color = .red
}
}
)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView(poly: Poly(data: PolyData(at: CGPoint(x:200,y:200), color: .blue, sides: 3, radius: 100)))
}
}
}
Can you give the following a try?
import SwiftUI
let twoPi:CGFloat = CGFloat.pi * 2
let pi = CGFloat.pi
struct ContentView: View {
#State var location: CGPoint = CGPoint(x: 200, y: 200)
#State var color: Color = .blue
#State var angle: CGFloat = 0
var body: some View {
PolyShape(location: location, color: color, angle: angle, sides: 3, vertexRadius: 100)
.fill(color)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onEnded { gesture in
withAnimation(.easeInOut(duration:1)) {
//This is what doesn't work.
//Try to nudge it a fraction of a pixel, and do only 1/3 of a turn, and it spins and moves much further.
location.x = 220
location.y = 220
angle = (CGFloat.pi * 2) / 3
color = .red
}
}
)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
Group {
ContentView(location: CGPoint(x: 200, y: 200), color: .blue, angle: 0)
}
}
}
struct PolyShape:Shape {
var location: CGPoint
var color: Color
var angle: CGFloat
var sides: CGFloat
var vertexRadius: CGFloat
public var animatableData: AnimatablePair<CGFloat, AnimatablePair<CGFloat,CGFloat>> {
get {
return AnimatablePair(angle, AnimatablePair(location.x, location.y))
}
set {
angle = newValue.first
location.x = newValue.second.first
location.y = newValue.second.second
}
}
func path(in rect: CGRect) -> Path {
var path = Path()
var radial:CGFloat = 0.5
while radial < twoPi + 0.5 {
let radialAngle = twoPi / sides * radial + angle
let newX = location.x + cos(radialAngle) * vertexRadius
let newY = location.y + sin(radialAngle) * vertexRadius
if radial == 0.5 {
path.move(to: CGPoint(x: newX, y: newY))
} else {
path.addLine(to: CGPoint(x: newX, y: newY))
}
radial += 1
}
return path
}
}

SwiftUI access a view created in a ForEach loop

Is there a way to access a view created in a ForEach loop? I'm creating views (Rectangles) with this struct on this loop. I want to change the fill color of the rects upon the tapped gestures.
struct DisplayingRect:Identifiable {
var id = UUID()
var width:CGFloat = 0
var height:CGFloat = 0
var xAxis:CGFloat = 0
var yAxis:CGFloat = 0
init(width:CGFloat, height:CGFloat, xAxis:CGFloat, yAxis:CGFloat) {
self.width = width
self.height = height
self.xAxis = xAxis
self.yAxis = yAxis
}
}
ForEach(self.rects) { rect in
Rectangle()
.fill(Color.init(.sRGB, red: 1, green: 0, blue: 0, opacity: 0.2))
.frame(width: rect.width, height: rect.height)
.offset(x: rect.xAxis, y: rect.yAxis)
.id(rect.id)
.onTapGesture {
print("Clicked")
self.rectTapped = rect.width
print(rect.width)
print(rect.id)
if !self.didTap {
self.didTap = true
} else {
self.didTap = false
}
}
I can assign each view with an id setting its id property, but I don't know where they are stored or how to modify them upon the click. I can create function that returns a view (Rectangle) and store them in an array, and display them in the screen, but again I don't know how to access them and modify the one I want.
Keep a #State to track which indices are highlighted then make your color a function of that state. Here is an example with animation:
struct ContentView: View {
#State private var selectedIndices = Set<Int>()
var body: some View {
ForEach (0..<3) { index in
Color(self.selectedIndices.contains(index) ? .yellow : .blue)
.frame(width: 200, height: 200)
.animation(.easeInOut(duration: 0.25))
.onTapGesture {
if self.selectedIndices.contains(index) {
self.selectedIndices.remove(index)
} else {
self.selectedIndices.insert(index)
}
}
}
}
}
you can do it like this:
struct DisplayingRect:Identifiable, Hashable {
static var counter = 0
var id : Int = DisplayingRect.counter
var width:CGFloat = 0
var height:CGFloat = 0
var xAxis:CGFloat = 0
var yAxis:CGFloat = 0
var color: Color = Color.red
init(width:CGFloat, height:CGFloat, xAxis:CGFloat, yAxis:CGFloat) {
self.width = width
self.height = height
self.xAxis = xAxis
self.yAxis = yAxis
DisplayingRect.counter = DisplayingRect.counter + 1
}
}
struct ContentView : View {
#State var rects : [DisplayingRect] = [
DisplayingRect(width: 30, height: 30, xAxis: 0, yAxis: 0),
DisplayingRect(width: 50, height: 50, xAxis: 50, yAxis: 50)
]
func setColorToID(_ id: Int) {
rects[id].color = Color.blue
}
var body: some View {
ForEach(self.rects, id: \.self) { rect in
Rectangle()
.fill(rect.color)
.frame(width: rect.width, height: rect.height)
.offset(x: rect.xAxis, y: rect.yAxis)
.id(rect.id)
.onTapGesture {
print(rect.id)
self.setColorToID(rect.id)
}
}
}
}
SwiftUI encourages a declarative approach – you shouldn't need to (and in fact can't) access any view directly to store a reference to it. Your views can be given data, and whenever that data changes, they'll update.
In this case, you could have your DisplayingRect store a color property, then have the tap gesture on each Rectangle look up the right struct in your rects array by ID, and modify the color property.
To separate out the logic from your view and make more of this unit testable, you might want to create some kind of view model class that encompasses this, but putting it all inside your view would work without these benefits.
This approach could look something like this (test locally & working):
struct DisplayingRect: Identifiable {
let id = UUID()
var color = Color.red
var width: CGFloat
var height: CGFloat
var xAxis: CGFloat
var yAxis: CGFloat
init(
width: CGFloat,
height: CGFloat,
xAxis: CGFloat = 0,
yAxis: CGFloat = 0)
{
self.width = width
self.height = height
self.xAxis = xAxis
self.yAxis = yAxis
}
}
final class ContentViewModel: ObservableObject {
#Published
private(set) var rects: [DisplayingRect] = [
.init(width: 100, height: 100),
.init(width: 100, height: 100),
.init(width: 100, height: 100)
]
func didTapRectangle(id: UUID) {
guard let rectangleIndex = rects.firstIndex(where: { $0.id == id }) else {
return
}
rects[rectangleIndex].color = .blue
}
}
struct ContentView: View {
#ObservedObject
var viewModel = ContentViewModel()
var body: some View {
VStack {
ForEach(viewModel.rects) { rect in
Rectangle()
.fill(rect.color)
.frame(width: rect.width, height: rect.height)
.offset(x: rect.xAxis, y: rect.yAxis)
.onTapGesture {
self.viewModel.didTapRectangle(id: rect.id)
}
}
}
}
}
In this case, the #ObservedObject property wrapper along with ObservableObject protocol allow the view to update itself whenever data it uses from viewModel is changed. To automatically signal properties that should cause the view to refresh, the #Published property wrapper is used.
https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-observedobject-to-manage-state-from-external-objects

Resources