ScrollViewProxy.scrollTo scrolls to wrong position when called from onAppear - ios

iOS 16.2, Xcode 14.2
I'm trying to scroll to a specific item in a List when I enter a view. I tried using onAppear that calls proxy.scrollTo. I added the sleep to make it work more often, and it usually scrolls down to show the item, but sometimes it only scrolls partway and the selected item isn't visible. Even after sleeping 3s, the scroll doesn't always go to the right place. However, tapping the CardView always scrolls to the right position.
So, I see that https://developer.apple.com/documentation/swiftui/scrollviewreader has a new note: "You may not use the ScrollViewProxy during execution of the content view builder; doing so results in a runtime error. Instead, only actions created within content can call the proxy, such as gesture handlers or a view’s onChange(of:perform:) method." I guess that might explain the behavior I'm seeing, but then how can I reliably scroll to the item on initial render?
struct CardGroupView: View {
var listItems: [ListItem]
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { proxy in
List(listItems) { _ in
CardView()
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.listRowSeparator(.hidden)
.onTapGesture {
// this works 100% of the time
proxy.scrollTo("df87ea1b-eae8-4989-a126-df49b8570a8d")
}
}
.listStyle(.plain)
.onAppear {
// do in a Task and sleep to rule out render timing issue
Task { #MainActor in
try await Task.sleep(for: .milliseconds(3000))
// this doesn't always work -- sometimes it scrolls only partway to the item
proxy.scrollTo("df87ea1b-eae8-4989-a126-df49b8570a8d")
}
}
}
}
}
}
struct CardView: View {
var isCurrent: Bool
var body: some View {
VStack(spacing: 90) {
// some random stuff, it doesn't matter what's here
Divider()
Rectangle()
.foregroundColor(isCurrent ? Color.blue : Color.green)
.frame(width: 40, height: 40)
}
}
}

As a work around, you could try using DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {...}
instead of Task {...}. If you have a very large list, then increase the time. Seems to work for me.

Related

SwiftUI unexpected animations when toggling animations In .onAppear (using a GeometryReader's size)

I have a strange animation behavior in SwiftUI. I've tried to create a minimal view that demonstrates it below.
I want to animate in three circles with a fade and a scale effect (see column "What I Expect" below). However, the size of the circles depends on the width of the view, so I'm using a GeometryReader to get that.
I want to start the animation in .onAppear(perform:), but at the time that is called, the GeometryReader hasn't set the size property yet. What I end up with is the animation you see in "Unwanted Animation 1". This is due to the frames being animated from .zero to their correct sizes.
However, whenever I try to disable the animations for the frames by adding a .animation(nil, value: size) modifier, I get an extremely strange animation behavior (see "Unwanted Animation 2"). This I don't understand at all. It somehow adds a horizontal translation to the animation which makes it look even worse. Any ideas what's happening here and how to solve this?
Strangely, everything works fine if I use an explicit animation like this:
.onAppear {
withAnimation {
show.toggle()
}
}
But I want to understand what's going on here.
Thanks!
Update:
Would replacing .onAppear(perform:) with the following code be reasonable? This would trigger only once in the lifetime of the view, right when size changes from .zero to the correct value.
.onChange(of: size) { [size] newValue in
guard size == .zero else { return }
show.toggle()
}
What I Expect
Unwanted Animation 1
Unwanted Animation 2
import SwiftUI
struct TestView: View {
#State private var show = false
#State private var size: CGSize = .zero
var body: some View {
VStack {
circle
circle
circle
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.background {
GeometryReader { proxy in
Color.clear.onAppear { size = proxy.size }
}
}
.onAppear { show.toggle() }
}
private var circle: some View {
Circle()
.frame(width: circleSize, height: circleSize)
.animation(nil, value: size) // This make the circles animate in from the side for some reason (see "Strange Animation 2")
.opacity(show ? 1 : 0)
.scaleEffect(show ? 1 : 2)
.animation(.easeInOut(duration: 1), value: show)
}
private var circleSize: Double {
size.width * 0.2 // Everything works fine if this is a constant
}
}
struct TestView_Previews: PreviewProvider {
static var previews: some View {
TestView()
}
}
The real size is known after first layout, but .onAppear is called before, so layout (including frame change, which is animatable) goes under animation.
To solve this we need to delay state change a bit (until first layout/render finished), like
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
show.toggle()
}
}
... and this is why withAnimation also works - it actually delays closure call to next cycle.
Tested with Xcode 13 / iOS 15

Buttons in SwiftUI List ForEach view trigger even when not "tapped"?

I have the following code:
struct ButtonTapTest: View {
let items = [1, 2, 3]
var body: some View {
List {
ForEach(items, id:\.self) { item in
CellTestView()
}
}
}
}
struct CellTestView:View {
var body: some View {
VStack {
Button {
print("TOP")
} label: {
Image(systemName: "play.fill")
.font(.system(size: 40))
.foregroundColor(.red)
}
.border(.red)
Spacer()
Button {
print("BOTTOM")
} label: {
Image(systemName: "play")
.font(.system(size: 40))
.foregroundColor(.red)
}
.border(.yellow)
}
}
}
Creates the following screen:
Problem:
Both button actions get triggered in the cell regardless of where I tap on the CellTestView. I want the individual button actions to trigger separately, each only when its button gets tapped, and not when I tap outside anywhere else on the cell.
You can see in the gif below that it does not matter where I tap on the CellTestView, both button actions get trigger regardless where on the view I tap, and both "TOP" and "BOTTOM" logs trigger at the same time.
How can I fix this so the two buttons in the cell receive the tap independently and only when the tap is inside the related button?
Whenever you have multiple buttons in a list row, you need to manually set the button style to .borderless or .plain. This is because buttons “adapt” to their context.
According to the documentation:
If you create a button inside a container, like a List, the style resolves to the recommended style for buttons inside that container for that specific platform.
So when your button is in a List, its tap target extends to fill the row and you get a highlight animation. SwiftUI isn’t smart enough to stop this side effect when you have more than 2 buttons, so you need to set buttonStyle manually.
CellTestView()
.buttonStyle(.borderless)
Result:
My workaround for this problem is to wrap each button in its own VStack or HStack.
when you have a list, tapping on a list row will trigger both buttons.
Try this code to make each "button" equivalent, trigger separately.
struct CellTestView: View {
var body: some View {
VStack {
Image(systemName: "play.fill") .font(.system(size: 40)).foregroundColor(.red).border(.red)
.onTapGesture {
print("-----> playFill \(playFill)")
}
Spacer()
Image(systemName: "play") .font(.system(size: 40)).foregroundColor(.red).border(.yellow)
.onTapGesture {
print("--> play \(play)")
}
}
}
}

How to use DispatchQueue to make a view appear and disappear after some time?

I want to make a view appear, do something, then disappear with DispatchQueue. I want to toggle the showHand to show the hand then toggleHandAnimation to make the hand move left and right. After some time e.g. 5 seconds, I want that hand to disappear.
I have an implementation below which seems to be working on the build but it seems like there would be a better way. I have the code below.
What is the guidance on implementing views where you want to run multiple tasks at different points in time async?
import SwiftUI
struct ContentView: View {
#State var toggleHandAnimation: Bool = false
#State var showHand: Bool = true
var body: some View {
if showHand {
Image(systemName: "hand.draw")
.font(Font.system(size: 100))
.offset(x: toggleHandAnimation ? -40 : 0, y: 0)
.animation(Animation.easeInOut(duration: 0.6).repeatCount(5))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1, execute: {
toggleHandAnimation.toggle()
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 5, execute: {
showHand.toggle()
})
})
}
}
}
}
I think this is what you are looking for:
struct ContentView: View {
#State var toggleHandAnimation: Bool = false
#State var showHand: Bool = true
var body: some View {
ZStack {
Color(uiColor: .systemBackground)
if showHand {
Image(systemName: "hand.draw")
.font(Font.system(size: 100))
.offset(x: toggleHandAnimation ? -40 : 0, y: 0)
.onAppear {
withAnimation(Animation.easeInOut(duration: 0.5).repeatCount(10)) {
toggleHandAnimation.toggle()
}
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 5, execute: {
showHand.toggle()
})
}
}
}
}
}
A few things, you should read up on intrinsic vs. extrinsic animations. You were trying to use an intrinsic one, but for a situation like this, it should be extrinsic(i.e., withAnimation(). Also, .animation(_ animation:) has been deprecated because it does not work well. You should be using .animation(_ animation:, value:).
Also, I aligned the time so that the animation ends and the hand disappears. The nice thing with using the withAnimation() is that you can animate multiple things occur with the same animation.
As you can see, you didn't need to nest theDispatchQueues. You only needed the one to make the view disappear.
Lastly, I put this all in a ZStack and set a color on that, so that there was always a view to return. The reason is, this will crash if there is suddenly no view being returned. That is what would happen at the end of the animation with out the color. Obviously, you can have whatever view you want, that was just an example.

SwiftUI ScrollView with Tap and Drag gesture

I'm trying to implement a ScrollView with elements which can be tapped and dragged. It should work the following way:
The ScrollView should work normally, so swiping up/down should not interfere with the gestures.
Tapping an entry should run some code. It would be great if there would be a "tap indicator", so the user knows that the tap has been registered (What makes this difficult is that the tap indicator should be triggered on touch down, not on touch up, and should be active until the finger gets released).
Long pressing an entry should activate a drag gesture, so items can be moved around.
The code below covers all of those requirements (except the tap indicator). However, I'm not sure why it works, to be specific, why I need to use .highPriorityGesture and for example can't sequence the Tap Gesture and the DragGesture with .sequenced(before: ...) (that will block the scrolling).
Also, I'd like to be notified on a touch down event (not touch up, see 2.). I tried to use LongPressGesture() instead of TapGesture(), but that blocks the ScrollView scrolling as well and doesn't even trigger the DragGesture afterwards.
Does somebody know how this can be achieved? Or is this the limit of SwiftUI? And if so, would it be possible to port UIKit stuff over to achieve this (I already tried that, too, but was unsuccessful, the content of the ScrollView should also be dynamic so porting over the whole ScrollView might be difficult)?
Thanks for helping me out!
struct ContentView: View {
var body: some View {
ScrollView() {
ForEach(0..<5, id: \.self) { i in
ListElem()
.highPriorityGesture(TapGesture().onEnded({print("tapped!")}))
.frame(maxWidth: .infinity)
}
}
}
}
struct ListElem: View {
#GestureState var dragging = CGSize.zero
var body: some View {
Circle()
.frame(width: 100, height: 100)
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
.updating($dragging, body: {t, state, _ in
state = t.translation
}))
.offset(dragging)
}
}
I tried a few option and I think a combination of sequenced and simultaneously allows two gestures to run the same time. To achieve a onTouchDown I used a DragGesture with minimum distance of 0.
struct ContentView: View {
var body: some View {
ScrollView() {
ForEach(0..<5, id: \.self) { i in
ListElem()
.frame(maxWidth: .infinity)
}
}
}
}
struct ListElem: View {
#State private var offset = CGSize.zero
#State private var isDragging = false
#GestureState var isTapping = false
var body: some View {
// Gets triggered immediately because a drag of 0 distance starts already when touching down.
let tapGesture = DragGesture(minimumDistance: 0)
.updating($isTapping) {_, isTapping, _ in
isTapping = true
}
// minimumDistance here is mainly relevant to change to red before the drag
let dragGesture = DragGesture(minimumDistance: 0)
.onChanged { offset = $0.translation }
.onEnded { _ in
withAnimation {
offset = .zero
isDragging = false
}
}
let pressGesture = LongPressGesture(minimumDuration: 1.0)
.onEnded { value in
withAnimation {
isDragging = true
}
}
// The dragGesture will wait until the pressGesture has triggered after minimumDuration 1.0 seconds.
let combined = pressGesture.sequenced(before: dragGesture)
// The new combined gesture is set to run together with the tapGesture.
let simultaneously = tapGesture.simultaneously(with: combined)
return Circle()
.overlay(isTapping ? Circle().stroke(Color.red, lineWidth: 5) : nil) //listening to the isTapping state
.frame(width: 100, height: 100)
.foregroundColor(isDragging ? Color.red : Color.black) // listening to the isDragging state.
.offset(offset)
.gesture(simultaneously)
}
}
For anyone interested here is a custom scroll view that will not be blocked by other gestures as mentioned in one of the comments. As this was not possible to be solved with the standard ScrollView.
OpenScrollView for SwiftUI on Github
Credit to
https://stackoverflow.com/a/59897987/12764795
http://developer.apple.com/documentation/swiftui/composing-swiftui-gestures
https://www.hackingwithswift.com/books/ios-swiftui/how-to-use-gestures-in-swiftui

SwiftUI: Is it possible to trigger a set PreferenceKey() from inside an .OnTapGesture?

I am developing an App with the following ContentView Structure
ContentView {
---HeaderView
---Collection view using QGrid (An Array of Cells), data source: an array of Structs
---A Detail View of data from a single data Struct from the data array
}
The goal is to update the Detail subView with data from a tapped cell in the QGrid.
I have a cell view which recognizes taps and correctly reports which cell to Console. The .OnTap correctly modifies the Cell view, but I can only attach the Preference setting to outside the Cell view, so it triggers the change in Pref every time a cell is displayed in QGrid leaving the reported cell as the last one in the grid, but never updates to the selected cell when tapped!
struct GlyphCell: View {
var glyph:Glyph
var body: some View {
ZStack {
Text("\(glyph.Hieroglyph)")
.lineLimit(1)
.padding(.all, 12.0)
.font(.system(size: 40.0))
.background(Color.yellow)
Text("\(glyph.Gardiner)")
.offset(x: 12, y: 26)
.foregroundColor(.blue)
}.onTapGesture {
print("Tap on \(self.glyph.Gardiner)")
DispatchQueue.main.async {
// would like to update preference in here!
}
}
.preference(key: TappedGlyphPreferenceKey.self, value: self.glyph)
.cornerRadius(6.0)
.frame(width: 90.0, height: 100.0, alignment: .center)
.previewLayout(.sizeThatFits)
}
}
Can this be accomplished with this approach, i.e., passing a Pref up to a parent view then down to the target Detail view? I tried both State and Observables wrappers on the selected data Struct but got nowhere with them. Any thoughts welcome!
Update: I must have a faulty understanding of State/Bind. I have a #State var of the data Struct in the parent Content View which I use update the Detail subView. When I added the #Bind of that Struct var to the Cell subview, the compiler then needs me to add that var to the parameter list for the Cell Sub-view. When I add it, I get all kinds of errors. I tried a number of variants, but gave up on Bind to try prefs. Recall, I want to pass the selected Cell Struct up the tree, then down to Detail. I can try to recreate the error if you would be generous enough to take a peek.
Try the following (cannot test it due to many dependencies absent, so just idea)
struct GlyphCell: View {
var glyph:Glyph
#State private var selected = false // << track own selection
var body: some View {
ZStack {
Text("\(glyph.Hieroglyph)")
.lineLimit(1)
.padding(.all, 12.0)
.font(.system(size: 40.0))
.background(Color.yellow)
Text("\(glyph.Gardiner)")
.offset(x: 12, y: 26)
.foregroundColor(.blue)
}.onTapGesture {
print("Tap on \(self.glyph.Gardiner)")
self.selected.toggle() // << async not required here
}
.cornerRadius(6.0)
.frame(width: 90.0, height: 100.0, alignment: .center)
.previewLayout(.sizeThatFits)
.background(Group {
if self.selected {
// activate preference only when selected
Color.clear
.preference(key: TappedGlyphPreferenceKey.self, value: self.glyph)
}
}
}
}
Another possible approach:
Keep the preference value as a State variable.
On tap gesture change the state variable.
Attach preference modifier to the view and set the State variable as preference value.

Resources