Updating targetContentOffset in scrollViewWillEndDragging to a value in wrong direction does not animate
Asked Answered
I

3

8

I am setting a new value to targetContentOffset in scrollViewWillEndDragging(_:withVelocity:targetContentOffset:) to create a custom paging solution in a UITableView. It works as expected when setting a coordinate for targetContentOffset that is in the same scroll direction as the velocity is pointing.

However, when snapping "backward" in the opposite direction of the velocity it does immediatelly "snap back" without animation. This looks quite bad. Any thoughts on how to solve this.

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
    // a lot of caluculations to detemine where to "snap scroll" to
    if shouldSnapScrollUpFromSpeed || shouldSnapScrollUpFromDistance {
        targetContentOffset.pointee.y = -scrollView.frame.height
    } else if shouldSnapScrollDownFromSpeed || shouldSnapScrollDownFromDistance {
        targetContentOffset.pointee.y = -detailViewHeaderHeight
    }
}

I could potentionally calculate when this "bug" will appear and perhaps use another way of "snap scrolling". Any suggestions on how to do this or solve it using targetContentOffset.pointee.y as normal?

Inspection answered 15/6, 2017 at 9:1 Comment(1)
The title of your question itself saved my day. Thank you very much. I couldn't get myself under which conditions the animation breaks. In my case, I'm going to fix it by auto-scrolling always to "right" direction. Thanks again!Watcher
I
1

I found an okey solution (or workaround). Let me know if anyone have a better solution.

I just detected when the undesired "non-animated snap scroll" would appear and then instead of setting a new value to targetContentOffset.pointee.y I set it to the current offset value (stopped it) and set the desired offset target value with scrollViews setContentOffset(_:animated:) instead

if willSnapScrollBackWithoutAnimation {
    targetContentOffset.pointee.y = -scrollView.frame.height+yOffset //Stop the scrolling
    shouldSetTargetYOffsetDirectly = false
}

if let newTargetYOffset = newTargetYOffset {
    if shouldSetTargetYOffsetDirectly {
        targetContentOffset.pointee.y = newTargetYOffset
    } else {
        var newContentOffset = scrollView.contentOffset
        newContentOffset.y = newTargetYOffset
        scrollView.setContentOffset(newContentOffset, animated: true)
    }
}
Inspection answered 15/6, 2017 at 9:49 Comment(5)
How did you detect willSnapScrollBackWithoutAnimation?Thelmathem
willSnapBackWithoutAnimation is set is to true when scroll is within a snapable interval (yOffset of scroll) and velocity is below a desired value (used 1.0) and velocity is not 0.Inspection
@Inspection why not just post the actual code for willSnapScrollBackWithoutAnimation than having to explain it?Hypozeugma
Good question. Should have done that. Do not have access to the code anymore unfortunately.Inspection
@Inspection hej while this is a great idea on your old answer here, it's a bit easier than this ..Recession
R
1

IMO the only solution to this problem is the "@sunkas solution". It is conceptually simple but there is a HUGE amount of detail to attend to.

func scrollViewWillEndDragging(_ sv: UIScrollView,
  withVelocity vel: CGPoint,
  targetContentOffset: UnsafeMutablePointer<CGPoint>) {

  print("Apple wants to end at \(targetContentOffset.pointee)")
  ...

Let's say UIKit wants the throw to end at "355.69".

  ...
  ... your calculations and logic
  solve = 280

You do YOUR calculation and logic. You want the throw to end at 280.

It's actually this simple, conceptually:

  ... your calculations and logic
  solve = 280
targetContentOffset.pointee.y = solve
UIView.animate(withDuration: secs, delay: 0, options: .curveEaseOut, animations: {
    theScroll.contentOffset.y = solve
})

That's all there is to it.

However there are MANY complications for a perfect result that matches UIKit behavior and feel.

  • it is a huge chore figuring out your "solve", where you want it to land next.

  • you need a lot of code to decide whether the user actually threw it (to your next stop position), or, whether they just "moved their finger around a little, and then let go"

  • if they did just "move their finger around a little, and then let go" you have to decide whether your next solve is the one they started on, the "next" one or the "previous" one of your clickstops.

  • a difficult issue is, in reality you'll wanna animate it using spring physics so as to match the normal stop that UIKit would do

  • even trickier, when you do the spring animation, you need to figure out and match the throw speed of the finger or it looks wrong

It's a real chore.

Recession answered 10/3, 2023 at 13:14 Comment(4)
Thanks for nothing I guessCephalothorax
Heh - what is it you're after champ ? I gave the full code to do it. Regarding the other details (the five bullet points) unfortunately it's impossible to have "a solution" to those. For example, with point 1, it depends totally on your screen and how it works. Regarding say point 4, you can see "example code for spring physics" anywhere (you just add an argument to UIView.animate), unfortunately each case will be totally different so I can't "give code". If you glance at my answers page, you can see I give out quite a bit of famous code solutions :) Is there something specific you need?Recession
Just how to animate in the other direction @Recession like the OP asked.Cephalothorax
ah friend, the one I gave will animate in both directionsRecession
C
0

In the following code threshold is regarded as the trigger point for moving content in the same direction as the drag. If this threshold is not met, then the content returns to its original position (in the opposite direction).

The target content offset is expecting a value in the direction of its momentum, so you need to stop the momentum first. Then just move the view using a standard animation block.

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        
        let distance = distanceTravelled(scrollView)
                
        let isForwardDirection = (velocity.x > 0 || distance < 0)
        
        if threshold(velocity, distance) {
            let rect = makeRect(scrollView)
            if let attribute = attribute(for: rect, isForwardDirection) {
                currentIndexPath = attribute.indexPath
                targetContentOffset.pointee = point(for: attribute)
            }
        } else if let attribute = flowLayout.layoutAttributesForItem(at: currentIndexPath) {
            targetContentOffset.pointee = scrollView.contentOffset
            DispatchQueue.main.async {
                let point = self.point(for: attribute)
                UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseOut, .allowUserInteraction]) {
                    scrollView.contentOffset = point
                }
            }
        }
    }
Cephalothorax answered 12/3, 2023 at 4:21 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.