SwiftUI: Drawing a line between two views in a list / how to determine the center location of a view? - foreach

I want to draw a line between a selected source-View and a selected target-View when I press the "connect" button.
As far as I understand I need the (center?) CGPoint of these views...
How can I determine the center CGPoint of a view inside a forEach-HStack-list?
Elements from both lists will be removed/added.
struct ContentView: View {
#State private var selectedTarget: Int = 0
#State private var selectedSource: Int = 3
#State private var isConnected = false
var body: some View {
ZStack {
VStack {
HStack {
ForEach(0..<6) { index in
Text("Target \(index)")
.padding()
.foregroundColor(.white)
.background(selectedTarget == index ? .red : .black)
.onTapGesture {
selectedTarget = index
}
}
}
List {
EmptyView()
}
.frame(width: 400, height: 400, alignment: .center)
.border(.black, width: 2)
HStack {
ForEach(0..<6) { index in
Text("Source \(index)")
.padding()
.foregroundColor(.white)
.background(selectedSource == index ? .orange : .black)
.onTapGesture {
selectedSource = index
}
}
}
Button(isConnected ? "CUT" : "CONNECT") {
isConnected.toggle()
}
.padding()
}
}
}
}

Here is the rough demo code and take an idea from this code.
You can achieve this by first finding the position of the Text by GeometryReader and draw the path between those points.
struct ContentView: View {
#State private var selectedTarget: Int = 0
#State private var selectedSource: Int = 3
#State private var isConnected = false
#State private var targetCGPoint: CGPoint = .zero
#State private var sourceCGPoint: CGPoint = .zero
var body: some View {
ZStack {
if isConnected {
getPath()
}
VStack {
HStack {
ForEach(0..<6) { index in
GeometryReader { geo in
Text("Target \(index)")
.padding()
.foregroundColor(.white)
.background(selectedTarget == index ? Color.red : Color.black)
.onTapGesture {
let size = geo.size
targetCGPoint = CGPoint(x: geo.frame(in: .global).origin.x + (size.width / 2), y: geo.frame(in: .global).origin.y)
print("Target Global center: \(geo.frame(in: .global).midX) x \(geo.frame(in: .global).midY)")
selectedTarget = index
}
}
}
}
// Removed list for demo
Spacer()
HStack {
ForEach(0..<6) { index in
GeometryReader { geo in
Text("Source \(index)")
.padding()
.foregroundColor(.white)
.background(selectedSource == index ? Color.orange : Color.black)
.onTapGesture {
let size = geo.size
sourceCGPoint = CGPoint(x: geo.frame(in: .global).origin.x + (size.width / 2), y: geo.frame(in: .global).origin.y)
print("Source Global center: \(geo.frame(in: .global).midX) x \(geo.frame(in: .global).midY)")
selectedSource = index
}
}
}
}
Button(isConnected ? "CUT" : "CONNECT") {
isConnected.toggle()
}
.padding()
}
}
}
func getPath() -> some View {
Path { path in
path.move(to: sourceCGPoint)
path.addLine(to: targetCGPoint)
}
.stroke(Color.blue, lineWidth: 10)
}
}

Related

Expandable Custom Segmented Picker in SwiftUI

I'm trying to create an expandable segmented picker in SwiftUI, I've done this so far :
struct CustomSegmentedPicker: View {
#Binding var preselectedIndex: Int
#State var isExpanded = false
var options: [String]
let color = Color.orange
var body: some View {
HStack {
ScrollView(.horizontal) {
HStack(spacing: 4) {
ForEach(options.indices, id:\.self) { index in
let isSelected = preselectedIndex == index
ZStack {
Rectangle()
.fill(isSelected ? color : .white)
.cornerRadius(30)
.padding(5)
.onTapGesture {
preselectedIndex = index
withAnimation(.easeInOut(duration: 0.5)) {
isExpanded.toggle()
}
}
}
.shadow(color: Color(UIColor.lightGray), radius: 2)
.overlay(
Text(options[index])
.fontWeight(isSelected ? .bold : .regular)
.foregroundColor(isSelected ? .white : .black)
)
.frame(width: 80)
}
}
}
.transition(.move(edge: .trailing))
.frame(width: isExpanded ? 80 : CGFloat(options.count) * 80 + 10, height: 50)
.background(Color(UIColor.cyan))
.cornerRadius(30)
.clipped()
Spacer()
}
}
}
Which gives this result :
Now, when it contracts, how can I keep showing the item selected and hide the others ? (for the moment, the item on the left is always shown when not expanded)
Nice job. You can add an .offset() to the contents of the ScollView, which shifts it left depending on the selection:
HStack {
ScrollView(.horizontal) {
HStack(spacing: 4) {
ForEach(options.indices, id:\.self) { index in
let isSelected = preselectedIndex == index
ZStack {
Rectangle()
.fill(isSelected ? color : .white)
.cornerRadius(30)
.padding(5)
.onTapGesture {
preselectedIndex = index
withAnimation(.easeInOut(duration: 0.5)) {
isExpanded.toggle()
}
}
}
.shadow(color: Color(UIColor.lightGray), radius: 2)
.overlay(
Text(options[index])
.fontWeight(isSelected ? .bold : .regular)
.foregroundColor(isSelected ? .white : .black)
)
.frame(width: 80)
}
}
.offset(x: isExpanded ? CGFloat(-84 * preselectedIndex) : 0) // <<< here
}
.transition(.move(edge: .trailing))
.frame(width: isExpanded ? 80 : CGFloat(options.count) * 80 + 10, height: 50)
.background(Color(UIColor.cyan))
.cornerRadius(30)
.clipped()
Spacer()
}
Here is another approach using .matchedGeometryEffect, which can handle different label widths without falling back to GeometryReader.
Based on expansionState it either draws only the selected item or all of them and .matchedGeometryEffect makes sure the animation goes smooth.
struct CustomSegmentedPicker: View {
#Binding var preselectedIndex: Int
#State var isExpanded = false
var options: [String]
let color = Color.orange
#Namespace var nspace
var body: some View {
HStack {
HStack(spacing: 8) {
if isExpanded == false { // show only selected option
optionLabel(index: preselectedIndex)
.id(preselectedIndex)
.matchedGeometryEffect(id: preselectedIndex, in: nspace, isSource: true)
} else { // show all options
ForEach(options.indices, id:\.self) { index in
optionLabel(index: index)
.id(index)
.matchedGeometryEffect(id: index, in: nspace, isSource: true)
}
}
}
.padding(5)
.background(Color(UIColor.cyan))
.cornerRadius(30)
Spacer()
}
}
func optionLabel(index: Int) -> some View {
let isSelected = preselectedIndex == index
return Text(options[index])
.fontWeight(isSelected ? .bold : .regular)
.foregroundColor(isSelected ? .white : .black)
.padding(8)
.background {
Rectangle()
.fill(isSelected ? color : .white)
.cornerRadius(30)
}
.onTapGesture {
preselectedIndex = index
withAnimation(.easeInOut(duration: 0.5)) {
isExpanded.toggle()
}
}
}
}

Custom picker switching views in SwiftUI

I am developing an app with a custom dock and I am unsure of how to change the view when highlighted on an item in the dock. I just couldn't find a way to switch the view when you highlight the item. I have attempted methods such as a switch statement but that did not work in my scenario. I have also attempted to use an if-else statement but that also did not work. I would much appreciate your help in finding a solution to this issue. Please review my code below...
struct MathematicallyController: View {
#State var selection: Int = 1
var body: some View {
ZStack {
ZStack {
if selection == 0 {
//view 1
} else if selection == 1 {
//view 2
} else if selection == 2 {
//view 3
} else {
//view 1
}
}
.overlay(
VStack {
Spacer()
ZStack {
BlurView(style: .systemThinMaterialDark)
.frame(maxWidth: .infinity, maxHeight: 65)
.cornerRadius(20)
.padding()
RoundedRectangle(cornerRadius: 20)
.stroke(lineWidth: 0.9)
.frame(maxWidth: .infinity, maxHeight: 65)
.blur(radius: 2)
.padding()
Picker(selection: selection)
.padding(5)
}
.offset(y: 30)
}
)
}
}
}
Picker
extension View {
func eraseToAnyView() -> AnyView {
AnyView(self)
}
}
struct SizePreferenceKey: PreferenceKey {
typealias Value = CGSize
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
struct BackgroundGeometryReader: View {
var body: some View {
GeometryReader { geometry in
return Color
.clear
.preference(key: SizePreferenceKey.self, value: geometry.size)
}
}
}
struct SizeAwareViewModifier: ViewModifier {
#Binding private var viewSize: CGSize
init(viewSize: Binding<CGSize>) {
self._viewSize = viewSize
}
func body(content: Content) -> some View {
content
.background(BackgroundGeometryReader())
.onPreferenceChange(SizePreferenceKey.self, perform: { if self.viewSize != $0 { self.viewSize = $0 }})
}
}
struct SegmentedPicker: View {
private static let ActiveSegmentColor: Color = Color(.tertiarySystemBackground)
private static let BackgroundColor: Color = Color(.secondarySystemBackground)
private static let ShadowColor: Color = Color.white.opacity(0.2)
private static let TextColor: Color = Color(.secondaryLabel)
private static let SelectedTextColor: Color = Color(.label)
private static let TextFont: Font = .system(size: 12)
private static let SegmentCornerRadius: CGFloat = 12
private static let ShadowRadius: CGFloat = 10
private static let SegmentXPadding: CGFloat = 16
private static let SegmentYPadding: CGFloat = 9
private static let PickerPadding: CGFloat = 7
private static let AnimationDuration: Double = 0.2
// Stores the size of a segment, used to create the active segment rect
#State private var segmentSize: CGSize = .zero
// Rounded rectangle to denote active segment
private var activeSegmentView: AnyView {
// Don't show the active segment until we have initialized the view
// This is required for `.animation()` to display properly, otherwise the animation will fire on init
let isInitialized: Bool = segmentSize != .zero
if !isInitialized { return EmptyView().eraseToAnyView() }
return
RoundedRectangle(cornerRadius: SegmentedPicker.SegmentCornerRadius)
.fill(.regularMaterial)
.shadow(color: SegmentedPicker.ShadowColor, radius: SegmentedPicker.ShadowRadius)
.frame(width: self.segmentSize.width, height: self.segmentSize.height)
.offset(x: self.computeActiveSegmentHorizontalOffset(), y: 0)
.animation(Animation.linear(duration: SegmentedPicker.AnimationDuration))
.overlay(
RoundedRectangle(cornerRadius: SegmentedPicker.SegmentCornerRadius)
.stroke(lineWidth: 1)
.shadow(color: SegmentedPicker.ShadowColor, radius: SegmentedPicker.ShadowRadius)
.frame(width: self.segmentSize.width, height: self.segmentSize.height)
.offset(x: self.computeActiveSegmentHorizontalOffset(), y: 0)
.animation(Animation.linear(duration: SegmentedPicker.AnimationDuration))
)
.eraseToAnyView()
}
#Binding private var selection: Int
private let items: [Image]
init(items: [Image], selection: Binding<Int>) {
self._selection = selection
self.items = items
}
var body: some View {
// Align the ZStack to the leading edge to make calculating offset on activeSegmentView easier
ZStack(alignment: .leading) {
// activeSegmentView indicates the current selection
self.activeSegmentView
HStack {
ForEach(0..<self.items.count, id: \.self) { index in
self.getSegmentView(for: index)
}
}
}
.padding(SegmentedPicker.PickerPadding)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: SegmentedPicker.SegmentCornerRadius))
}
// Helper method to compute the offset based on the selected index
private func computeActiveSegmentHorizontalOffset() -> CGFloat {
CGFloat(self.selection) * (self.segmentSize.width + SegmentedPicker.SegmentXPadding / 2)
}
// Gets text view for the segment
private func getSegmentView(for index: Int) -> some View {
guard index < self.items.count else {
return EmptyView().eraseToAnyView()
}
let isSelected = self.selection == index
return
Text(self.items[index])
// Dark test for selected segment
.foregroundColor(isSelected ? SegmentedPicker.SelectedTextColor: SegmentedPicker.TextColor)
.lineLimit(1)
.padding(.vertical, SegmentedPicker.SegmentYPadding)
.padding(.horizontal, SegmentedPicker.SegmentXPadding)
.frame(minWidth: 0, maxWidth: .infinity)
// Watch for the size of the
.modifier(SizeAwareViewModifier(viewSize: self.$segmentSize))
.onTapGesture { self.onItemTap(index: index) }
.eraseToAnyView()
}
// On tap to change the selection
private func onItemTap(index: Int) {
guard index < self.items.count else {
return
}
self.selection = index
}
}
struct Picker: View {
#State var selection: Int = 1
private let items: [Image] = [Image(systemName: "rectangle.on.rectangle"), Image(systemName: "timelapse"), Image(systemName: "plus")]
var body: some View {
SegmentedPicker(items: self.items, selection: self.$selection)
.padding()
}
}
In your Picker struct you are getting selection as a value not a Binding.
The purpose of using a Binding variable is to make the parent of the passed variable listen to the changes made in the struct. In other words, it binds the 2 views/values.
So what you should do is modify Picker like this:
struct Picker: View {
#Binding var selection: Int = 1
private let items: [Image] = [Image(systemName: "rectangle.on.rectangle"), Image(systemName: "timelapse"), Image(systemName: "plus")]
var body: some View {
SegmentedPicker(items: self.items, selection: self.$selection)
.padding()
}
}
And in MathematicallyController change Picker(selection: selection) into Picker(selection: $selection) so you'd have:
struct MathematicallyController: View {
#State var selection: Int = 1
var body: some View {
ZStack {
ZStack {
if selection == 0 {
//view 1
} else if selection == 1 {
//view 2
} else if selection == 2 {
//view 3
} else {
//view 1
}
}
.overlay(
VStack {
Spacer()
ZStack {
BlurView(style: .systemThinMaterialDark)
.frame(maxWidth: .infinity, maxHeight: 65)
.cornerRadius(20)
.padding()
RoundedRectangle(cornerRadius: 20)
.stroke(lineWidth: 0.9)
.frame(maxWidth: .infinity, maxHeight: 65)
.blur(radius: 2)
.padding()
Picker(selection: $selection)
.padding(5)
}
.offset(y: 30)
}
)
}
}
}
Also Note that a switch statement will work just as fine as an if one.

How to pass and access property from one view to another in SwiftUI?

I have one view which I am using in another view, and i want to access property from one view to another...but i am not sure how to do it.. (My main problem with below code - I really appreciate if I got solution for this - is I am having dropdown even when it is collapsed its expanding when i clicked outside of it..which is wrong)
import SwiftUI
public struct TopSheet<Content>: View where Content : View {
private var content: () -> Content
let minHeight: CGFloat = 50.0
let startHeight: CGFloat = 50.0
let maxOpacity: CGFloat = 0.8
let maxArrowOffset: CGFloat = 8.0
let minimumContentHeight = 61.0
#State private var currentHeight: CGFloat = 0
#State private var contentHeight: CGFloat = 0
#State private var backgroundColor: Color = Color.clear
#State private var expand = false
#State private var arrowOffset: Double = 0
public init(#ViewBuilder content: #escaping () -> Content) { self.content = content }
public func expandRatio() -> Double { return max((currentHeight - minHeight) / contentHeight, 0) }
private func isTopSheetExpandable() -> Bool {
return contentHeight > minimumContentHeight
}
public var body: some View {
let tap = TapGesture()
.onEnded { _ in
expand.toggle()
if expand {
withAnimation(Animation.easeOut) {
currentHeight = max(contentHeight, minHeight)
self.backgroundColor = Color.grey
}
}
else {
withAnimation(Animation.easeOut) {
currentHeight = minHeight
self.backgroundColor = Color.clear
}
}
self.arrowOffset = expandRatio() * maxArrowOffset
}
let drag = DragGesture()
.onChanged { value in
currentHeight += value.translation.height
currentHeight = max(currentHeight, minHeight)
let opacity = min(expandRatio() * maxOpacity, maxOpacity)
self.backgroundColor = Color.gray.opacity(opacity)
self.arrowOffset = expandRatio() * maxArrowOffset
}
.onEnded { value in
expand.toggle()
if expand {
withAnimation(Animation.easeOut) {
currentHeight = max(contentHeight, minHeight)
self.backgroundColor = Color.gray.opacity(maxOpacity)
}
}
else {
withAnimation(Animation.easeOut) {
currentHeight = minHeight
self.backgroundColor = Color.clear
}
}
self.arrowOffset = expandRatio() * maxArrowOffset
}
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 0) {
VStack(spacing: 0) {
GeometryReader { geo in
content()
.viewHeight()
.fixedSize(horizontal: false, vertical: true)
}.onPreferenceChange(ViewHeightPreferenceKey.self) { height in
contentHeight = height
currentHeight = startHeight
}.clipped()
Spacer(minLength: 0)
}
.frame(height: currentHeight)
HStack(alignment: .center) {
Spacer()
if isTopSheetExpandable() {
Arrow(offset: arrowOffset)
.stroke(Color.gray, style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round))
.frame(width: 30, height: 4)
.padding(.bottom, 10)
.padding(.top, 10)
}
Spacer()
}
// .contentShape(Rectangle())
.gesture(drag)
.gesture(tap)
.animation(.easeInOut, value: 2)
}
.background(Color.white)
Spacer()
}
.background(self.backgroundColor.edgesIgnoringSafeArea([.vertical, .horizontal, .leading, .trailing, .top, .bottom]))
.simultaneousGesture(isTopSheetExpandable() ? tap : nil)
}
}
fileprivate struct Arrow: Shape {
var offset: Double
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: .zero)
path.addLine(to: CGPoint(x: rect.width/2, y: -offset))
path.move(to: CGPoint(x: rect.width/2, y: -offset))
path.addLine(to: CGPoint(x: rect.width, y: 0))
return path
}
}
Another view
import SwiftUI
struct NextView: View {
#State private var backgroundColor: Color = Color.gray
#State private var users: [String] = ["abc", "xyz", "pqr", "mno", "pqr", "ert", ""]
var accessibilityID: String
var body: some View {
ZStack {
Rectangle()
.fill()
.foregroundColor(backgroundColor)
TopSheet {
VStack(spacing: 0) {
ForEach($users, id: \.self) { user in
HStack {
Text(user.wrappedValue)
.padding(.vertical, 10)
Spacer()
}
.contentShape(Rectangle())
.simultaneousGesture(TapGesture().onEnded {
self.users = [user.wrappedValue] + self.users.filter { $0 != user.wrappedValue }
switch self.users.first.unsafelyUnwrapped {
case "Joe Black": self.backgroundColor = .black
case "Eva Green": self.backgroundColor = .green
case "Jared Leto": self.backgroundColor = .red
default: self.backgroundColor = .gray
}
})
}
}
.padding(.horizontal, 10)
}
}
}
}
I might try some trick if I can able to access "expand" property from "TopSheet" and access it in "NextView"
You should store expand as #State in your parent View and pass it via Binding. Simplified example:
public struct TopSheet<Content>: View where Content : View {
#Binding var expand : Bool
var content: () -> Content
//declare other properties private so they don't get used in the generated init
public var body: some View {
Text("Here")
}
}
struct NextView: View {
#State private var expand = false
var body: some View {
TopSheet(expand: $expand) {
Text("Content")
}
}
}
If you really want/need your explicit init, you can do this:
public struct TopSheet<Content>: View where Content : View {
#Binding private var expand : Bool
private var content: () -> Content
public init(expand: Binding<Bool>, #ViewBuilder content: #escaping () -> Content) {
self._expand = expand
self.content = content
}
public var body: some View {
Text("Here")
}
}

How to create dragable view from top in SwiftUI?

I am trying to create dragable view from top which is kind of overlay .. I am attaching something same but having bottom sheet but i am looking for Top sheet. When overlay come background should be blur.
I am not sure about how to implement hence dont have code..I tried parent child for List in SwiftUI but that is having diff. Behaviour.
Any example or suggestion is appropriated.
something like this?
struct SheetView: View {
let title: String
var body: some View {
NavigationView {
List {
ForEach(0..<30) { item in
Text("Item \(item)")
}
}
.listStyle(.plain)
.navigationTitle(title)
}
}
}
struct ContentView: View {
#State private var currentDrag: CGFloat = 0
#State private var stateDrag: CGFloat = -300
let minDrag: CGFloat = -UIScreen.main.bounds.height + 120
var body: some View {
ZStack {
VStack(spacing: 0) {
Spacer(minLength: 36)
SheetView(title: "Bottom Sheet")
.background(.primary)
.opacity(stateDrag + currentDrag > minDrag ? 0.5 : 1)
.blur(radius: stateDrag + currentDrag > minDrag ? 5 : 0)
}
VStack(spacing: 0) {
SheetView(title: "Top Sheet")
.background(.background)
.offset(x: 0, y: stateDrag + currentDrag)
Image(systemName: "line.3.horizontal").foregroundColor(.secondary)
.padding()
.frame(maxWidth: .infinity)
.background(.background)
.contentShape(Rectangle())
.offset(x: 0, y: stateDrag + currentDrag)
.gesture(DragGesture()
.onChanged { value in
withAnimation {
currentDrag = value.translation.height
}
}
.onEnded { value in
stateDrag += value.translation.height
stateDrag = max(stateDrag, minDrag)
currentDrag = .zero
})
}
}
}
}

How to make a view's height animate from 0 to height in SwiftUI?

I'd like to make this bar's height (part of a larger bar graph) animate on appear from 0 to its given height, but I can't quite figure it out in SwiftUI? I found this code that was helpful, but with this only the first bar (of 7) animates:
struct BarChartItem: View {
var value: Int
var dayText: String
var topOfSCaleColorIsRed: Bool
#State var growMore: CGFloat = 0
#State var showMore: Double = 0
var body: some View {
VStack {
Text("\(value)%")
.font(Font.footnote.monospacedDigit())
RoundedRectangle(cornerRadius: 5.0)
.fill(getBarColor())
.frame(width: 40, height: growMore)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation(.linear(duration: 7.0)) {
growMore = CGFloat(value)
showMore = 1
}
}
}.opacity(showMore)
Text(dayText.uppercased())
.font(.caption)
}
}
And this is the chart:
HStack(alignment: .bottom) {
ForEach(pastRecoveryScoreObjects) { pastRecoveryScoreObject in
BarChartItem(value: pastRecoveryScoreObject.recoveryScore, dayText: "\(pastRecoveryScoreObject.date.weekdayName)", topOfSCaleColorIsRed: false)
}
}
This works: you can get the index of the ForEach loop and delay each bar's animation by some time * that index. All the bars are connected to the same state variable via a binding, and the state is set immediately on appear. You can play with the parameters to tune it to your own preferences.
struct TestChartAnimationView: View {
let values = [200, 120, 120, 90, 10, 80]
#State var open = false
var animationTime: Double = 0.25
var body: some View {
VStack {
Spacer()
HStack(alignment: .bottom) {
ForEach(values.indices, id: \.self) { index in
BarChartItem(open: $open, value: values[index])
.animation(
Animation.linear(duration: animationTime).delay(animationTime * Double(index))
)
}
}
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
open = true
}
}
}
}
struct BarChartItem: View {
#Binding var open: Bool
var value: Int
var body: some View {
VStack {
Text("\(value)%")
.font(Font.footnote.monospacedDigit())
RoundedRectangle(cornerRadius: 5.0)
.fill(Color.blue)
.frame(width: 20, height: open ? CGFloat(value) : 0)
Text("Week".uppercased())
.font(.caption)
}
}
}
Edit: ForEach animations are kind of weird. I'm not sure why, but this works:
struct ContentView: View {
let values = [200, 120, 120, 90, 10, 80]
var body: some View {
HStack(alignment: .bottom) {
ForEach(values, id: \.self) { value in
BarChartItem(totalValue: value)
.animation(.linear(duration: 3))
}
}
}
}
struct BarChartItem: View {
#State var value: Int = 0
var totalValue = 0
var body: some View {
VStack {
Text("\(value)%")
.font(Font.footnote.monospacedDigit())
RoundedRectangle(cornerRadius: 5.0)
.fill(Color.blue)
.frame(width: 20, height: CGFloat(value))
Text("Week".uppercased())
.font(.caption)
}.onAppear {
value = totalValue
}
}
}
Instead of applying the animation in the onAppear, I applied an implicit animation inside the ForEach.
Result:

Resources