SwiftUI views inserted with ForEach not updating with animation - swiftui

I created a simple function that inserts three Buttons. I want an individual button to rotate when pressed. My initial try was this:
ForEach(0..<3, id: \.self) { number in {
Button(action: {
self.flagTapped(number: index)
withAnimation(Animation.interpolatingSpring(stiffness: 15.0, damping: 3.0)) {
animationAmount2 += 360
}
}) {
Image(self.countries[index])
.renderingMode(.original)
}
.clipShape(Capsule())
.overlay(Capsule().stroke(Color.black, lineWidth: 1)
.shadow(color: .black, radius: 10, x: 3, y: 3)
.rotation3DEffect(
.degrees(animationAmount2),
axis: (x: 0.0, y: 1.0, z: 0.0),
anchor: .center,
anchorZ: 0.0,
perspective: 1.0
)
}
It works, but the issue is that every button animates when you press any button because animationAmount2 is an #State property so when it is updated, every button animates, not just the one that was pressed.
My next thought was to create a custom button and insert the animation code and properties inside it, so that the buttons would animate individually. That resulted in:
func getFlagView(index: Int) -> some View {
let flag = CustomButton(country: countries[index], index: index) { (Index) in
flagTapped(number: index)
}
return flag
}
I now call this function in the ForEach, and it inserts the buttons perfectly, and only the button that I press rotates. The issue is that when the view refreshes it never redraws the buttons. The ForEach is executing, but it is like it simply ignores the calls to getFlagView.
Adding .id(UUID()) to the end of the CustomButton call fixed that:
func getFlagView(index: Int) -> some View {
let flag = CustomButton(country: countries[index], index: index) { (Index) in
flagTapped(number: index)
}.id(UUID())
return flag
}
Now the buttons redraw when the view refreshes as expected, but the animation doesn't work. I am really at loss as to why adding the UUID breaks the animations.

In order for SwiftUI to animate your button, it needs to be able to observe a change between renderings of a uniquely identified view. In your first case, your views had id's 0, 1, and 2. So the animation worked.
When you applied .id(UUID()), this gave the buttons a unique id every time they are drawn. So SwiftUI doesn't see that you've changed a button because it always sees the 3 buttons as 3 entirely new buttons every time ForEach executes.
What you need is an id that uniquely identifies each of the buttons, but it doesn't change until the countries change. You need an id that uniquely identifies each country, and that id is the country's name.
Change your getFlagView to use the country's name as the id:
func getFlagView(index: Int) -> some View {
let flag = CustomButton(country: countries[index], index: index) { (Index) in
flagTapped(number: index)
}.id(countries[index])
return flag
}

Related

List animations ignoring Animation parameter

I've been trying to get changes to my list elements to animate correctly. However, items in a list don't seem to animate as specified.
In this simple example, an element is removed. There is an animation, within 1 second the element is removed. However, it completely ignores the duration and delay.
struct ContentView: View {
#State var items = [1, 2, 3, 4, 5]
var body: some View {
VStack {
List {
ForEach(items, id: \.self) { item in
Text("Item \(item)")
}
}
Button {
withAnimation(Animation.easeInOut(duration: 5).delay(1)) {
print("removing element")
items.removeFirst()
}
} label: {
Text("Remove element")
}
}
}
}
If I remove the List and just have a VStack of items, the Animation parameter is processed correctly.
If I remove the withAnimation, it doesn't animate at all. So it is triggering it.
I would say that this is expected as this is how underlying UITableViewController is working, it doesn't have capabilities to customise animations that way.
I think that if you want to do something custom you need to start with LazyVStack in ScrollView, that will give you more space for creation and it is quite likely that this would work (I haven't tried it, but from the logical point it should).

How do we prevent the .onTapGesture on other views from firing when SwiftUI menu is visible on the screen?

Here is my code:
/* triggers a full screen view on parent to be able to trap clicks anywhere on the screen and prevent other views .ontap from firing.
This works when the use taps outside, when the user makes a change but does not work when the user selects the already selected menu item. In that case the menu does disappear but I can't toggle the parent's trapping view because .disappear for the menu or the picker doesn't actually fire.
*/
#Binding var menuShowing: Bool
....
Menu {
Picker("Topic", selection: $selectedTopic) {
Text("Unassigned")
.tag(nil as Topic?)
Divider()
ForEach(topics, id:\.id) { topic in
Text(topic.name!)
.tag(topic as Topic?)
}
}
.onDisappear{
print("Picker Disappeared") // THIS DOES NOT FIRE IF THE USER SELECTS THE ALREADY SELECTED ITEM
}
}
label: {
Image(systemName: "circle.grid.3x3.circle")
.font(.system(size: 26,weight: .ultraLight))
.foregroundColor(learningSet.topic?.foregroundColor ?? .primary)
.frame(width:30, height: 30)
}
.contentShape(Rectangle())
.onDisappear{ // THIS DOES NOT FIRE IF THE USER SELECTS THE ALREADY SELECTED ITEM
print("Menu Disappeared")
}
.onTapGesture {
vm.haptic()
menuShowing.toggle()
}
.onChange(of: selectedTopic, perform: { _ in
menuShowing = false
DispatchQueue.main.async {
if learningSet.topic != selectedTopic{
learningSet.lastModifiedOn = .now
learningSet.topic?.lastModifiedOn = .now
learningSet.topic = selectedTopic
if selectedTopic == nil{
vm.unassignedExpanded = true
} else{
selectedTopic?.lastModifiedOn = .now
selectedTopic?.expanded = true
}
_ = vm.save("Changed Topic for set \(learningSet.name ?? "..") to \(String(describing: selectedTopic?.name))")
withAnimation(.easeInOut(duration: 0.15)){
forceRefresh.toggle()
}
}
}
})
This correctly shows the menu and if I select an item, the .onchange fires like it should.
Problem is if the user clicks on some other view on the screen while the is active, it fires that views .onTappedGesture.
I tried different combinations of highPriorityGesture and SimultaneousGesture to no avail.
I also tried the approach of adding a full screen view In between the view with the menu and the underlying views and added an onTap to it. That approach works fine with one issue: If the user click on the currency selected item, the .onChange for the picker doesn't fire, the menu disappears but its .onDisappear doesn't fire. So there is no way to know the menu is no longer visible, and hide my full screen view.
Any help would be greatly appreciated.
Thanks.

How do I know a scrollview element is about to leave/entering the screen, so I can create an animation

I have a vertical scrollView with a lot of items. As the user scrolls I want to do some nice effect, for example, like a carousel or something. In fact, less dramatic, just the cells being shifted to the right as they are cut as the scroll out of view.
This is what I have to test.
ScrollView {
ForEach(0...50) { item in
GeometryReader { geometry in
Text(item)
.onChange(of: geometry.frame(in: .named("xxx")), perform: { value in
if item == 1 {
print(value.minY)}
})
}
}
}
.coordinateSpace(name: "xxx")
I have added this coordinateSpace and this .onChange, to see what is happening with one element in particular, element number 1.
I see number one's value.minY goes from 0 to -400, if I scroll up. When Y is 0, is when this element is fully on view.
But if I have N elements on that view, it will be very complex to know when every element is entering the view. At least I am not seeing how.
The idea is to apply an effect like a shift, rotation or whatever, when the element starts to go off view and unwind the effect when the element is full on view.
How?
I apologize if I misread your question, but your just trying to animate the objects when the come on or off the screen based on their location within the coordinate space? If so, here's an example of how to do it. It's basically what you had except you can use the geometry immediately on modifiers and don't need the .onChange.
The below code has a top & bottom threshold, calculates each item's % into the threshold and then updates modifiers accordingly.
import SwiftUI
struct ScrollAnimationView: View {
var body: some View {
ScrollView {
ForEach(0..<50) { item in
GeometryReader { geometry in
Text("NUMBER \(item), PERCENT: \(getPercentOfAnimation(geo: geometry))")
.font(.headline)
.offset(x: getPercentOfAnimation(geo: geometry) * UIScreen.main.bounds.width)
.opacity(1.0 - Double(getPercentOfAnimation(geo: geometry)))
.rotationEffect(
Angle(degrees: 10 * Double(getPercentOfAnimation(geo: geometry)))
)
.scaleEffect(1.0 - getPercentOfAnimation(geo: geometry))
}
}
}
.coordinateSpace(name: "xxx")
}
func getPercentOfAnimation(geo: GeometryProxy) -> CGFloat {
let threshold: CGFloat = UIScreen.main.bounds.height * 0.4
let currentY: CGFloat = geo.frame(in: .named("xxx")).minY
if currentY < threshold {
return 1 - currentY / threshold
}
let topThreshold: CGFloat = UIScreen.main.bounds.height * 0.6
if currentY > topThreshold {
return (currentY - topThreshold) / threshold
}
return 0
}
}

How to detect scroll up, scroll down, top and bottom in SwiftUI List

I have a List and I would like to hide a toolbar and tab bar when the List scrolls up.
I've tried to detect movements using the following:
.gesture(
DragGesture()
.onChanged { value in
if value.translation.height > 0 {
print("Scroll down")
} else {
print("Scroll up")
}
}
.onEnded{ value in
print("ended")
}
)
This works intermittently, as in only around 80% of the time, it only seems to detect scrolls when I do it slowly or when I place my finger down straight onto the screen and then scroll. Otherwise nothing. Also, onEnded never fires here.
I also tried this:
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged { value in
print(value.translation.height)
if value.translation.height > 0 {
print("Scroll down")
} else {
print("Scroll up")
}
}
.onEnded{ value in
print("ended")
print(value.location.y)
}
)
This catches all my scrolling, which is great. onEnded also fires, but the problem is my List doesn't actually scroll.
I'm at a loss here, I want to know when the user scrolls up, scrolls down, by how much, and if possible I would like to know when the user gets to the top of the list.
If you want to do this SwiftUI only you can make a GeometryReader inside the ScrollView to detect offset change like shown here: https://fivestars.blog/swiftui/scrollview-offset.html
If you don't mind using Introspect you can do it like described in this answer: https://stackoverflow.com/a/65467199/3393964
You can use Introspect to get the UIScrollView, then from that get the publisher for UIScrollView.contentOffset and UIScrollView.isDragging to get updates on those values which you can use to manipulate your SwiftUI views.
struct Example: View {
#State var isDraggingPublisher = Just(false).eraseToAnyPublisher()
#State var offsetPublisher = Just(.zero).eraseToAnyPublisher()
var body: some View {
...
.introspectScrollView {
self.isDraggingPublisher = $0.publisher(for: \.isDragging).eraseToAnyPublisher()
self.offsetPublisher = $0.publisher(for: \.contentOffset).eraseToAnyPublisher()
}
.onReceive(isDraggingPublisher) {
// do something with isDragging change
}
.onReceive(offsetPublisher) {
// do something with offset change
}
...
}
If you want to look at an example; I use this method to get the offset publisher in my package ScrollViewProxy.

SwiftUI ScrollView renders sub views too early before they enter screen

I recently started studying ios/swiftui by developing a small application, which has a list of cards to display picture and message loaded from server.
I was using List to hold those cards but the default list 'decoration' (divider, arrow, tapping effect) looks not very good together with my card view. I disable them by adding below code into SceneDelegate:
UITableView.appearance().separatorColor = .clear
UITableViewCell.appearance().selectionStyle = .none
And having some sort of hack to hide the arrow:
List {
ForEach(0..<self.store.data.count, id: \.self) { idx in
NavigationLink(destination: DetailView(item: self.store.data[idx])) {
EmptyView()
}.frame(width: 0).opacity(0)
ItemCard(item: self.store.data[idx])
}
BottomLoader().onAppear {
if self.store.status != .loading {
self.store.load()
}
}
}
But, the problem is that hiding List's selection style and separator color in SceneDelegate applies to all the lists in App, and I do have couple lists (such as the one in settings) need those style.
Then I tried to change the card holder from List to ScrollView but it leads to another trouble, the onAppear callback of last view (BottomLoader) gets called at the same time with ScrollView onAppear gets called.
As far as I understand, onAppear is supposed to be called when view is rendered and shown. List as the item holder, does not have to calculate the full height of all items, that is probably why List renders only the items about to enter screen. But ScrollView does need to know the full height of all items and that is why all the items get rendered.
Below are two small segments to show different behaviors of List and ScrollView:
List {
ForEach(0..<100) { idx in
Text("item # \(idx)").padding().onAppear { print("\(idx) on appear") }
}
}
// only 18 logs printed
And:
ScrollView {
ForEach(0..<100) { idx in
Text("item # \(idx)").padding().onAppear { print("\(idx) on appear") }
}
}
// all 100 logs printed
Is there any way, to let ScrollView acts like List, to render its subviews when they about to enter screen.
Or, is there any way, to disable those default styles to a specific List, not globally?