7

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?

Sunkas
  • 9,542
  • 6
  • 62
  • 102
  • 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! – Artem Stepanenko May 28 '18 at 08:50

3 Answers3

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)
    }
}
Sunkas
  • 9,542
  • 6
  • 62
  • 102
  • How did you detect `willSnapScrollBackWithoutAnimation`? – Natan R. Aug 21 '17 at 08:02
  • 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. – Sunkas Aug 21 '17 at 09:04
  • @Sunkas why not just post the actual code for `willSnapScrollBackWithoutAnimation` than having to explain it? – strangetimes May 06 '20 at 02:39
  • Good question. Should have done that. Do not have access to the code anymore unfortunately. – Sunkas Oct 25 '21 at 11:36
  • 1
    @Sunkas hej while this is a great idea on your old answer here, it's a bit easier than this .. – Fattie Mar 10 '23 at 12:38
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.

Fattie
  • 27,874
  • 70
  • 431
  • 719
  • Thanks for nothing I guess – bobby123uk Mar 11 '23 at 08:26
  • 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? – Fattie Mar 11 '23 at 15:27
  • forgot to add @bobby123uk tag – Fattie Mar 11 '23 at 15:37
  • Just how to animate in the other direction @Fattie like the OP asked. – bobby123uk Mar 12 '23 at 04:23
  • ah friend, the one I gave will animate ***in both directions*** – Fattie Mar 12 '23 at 19:36
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
                }
            }
        }
    }
bobby123uk
  • 892
  • 4
  • 17