When I ported my game Empire for macOS (see: https://apps.apple.com/de/app/empire/id1287139467?mt=12) to iOS (see: https://apps.apple.com/de/app/empire-mobile/id1465452819) I noticed a much nicer scrolling behaviour there. There are a lot of smaller and more fundamental differences in programming for these two operating systems, especially between AppKit and UIKit and animation is one of them.
So, in iOS I used the UIView method
1 |
func scrollRectToVisible(_ rect: CGRect, animated: Bool) |
to scroll my View (a map) embedded in a ScrollView to a specified rectangle around the blinking cursor. I set animated
to true
and the scrollView scrolls smooth and nice to the desired region. Fine.
In macOS there is a similar method for NSView:
1 |
func scrollToVisible(_ rect: NSRect) -> Bool |
Looks quite the same besides the additional parameter “animated
” in the iOS version. And that’s the point of this post. I want to have a similar function with scroll animation in macOS as well.
Looking around a little bit I found this discussion in stackoverflow which sounds quite promising. Well, I want to scroll to a rect not to a point but that’s not too difficult to adjust:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
private func scroll(toRect rect: CGRect, animationDuration duration: Double) { if let scrollView = enclosingScrollView { // we do have a scroll view let clipView = scrollView.contentView // and thats its clip view var newOrigin = clipView.bounds.origin // make a copy of the current origin if newOrigin.x > rect.origin.x { // we are too far to the right newOrigin.x = rect.origin.x // correct that } if rect.origin.x > newOrigin.x + clipView.bounds.width - rect.width { // we are too far to the left newOrigin.x = rect.origin.x - clipView.bounds.width + rect.width // correct that } if newOrigin.y > rect.origin.y { // we are too low newOrigin.y = rect.origin.y // correct that } if rect.origin.y > newOrigin.y + clipView.bounds.height - rect.height { // we are too high newOrigin.y = rect.origin.y - clipView.bounds.height + rect.height // correct that } NSAnimationContext.beginGrouping() // create the animation NSAnimationContext.current.duration = duration // set its duration clipView.animator().setBoundsOrigin(newOrigin) // set the new origin with animation scrollView.reflectScrolledClipView(clipView) // and inform the scroll view about that NSAnimationContext.endGrouping() // finaly do the animation } } |
Please note, that I use flipped coordinates in my NSView to make it match the iOS behaviour. So, this new method does the following
- If the view is really embedded in a scroll view I make a copy of the origin of the clipview’s bounds. This should be the origin of the currently visible rect in view coordinates.
- Then I shift this origin in x and y coordinates separately with the shortest possible movement to make the supplied rect completely visible (if it’s not too big).
- And finally I set this new bounds with animation as proposed in the mentioned stackoverflow post.
That’s fine but it turns out that there is a problem with bounds.origin
of the clipview. If the view is getting resized (e.g. by resizing the surrounding window) bounds.origin
is somehow shifted against the true origin of the visible rectangle in y-direction. I could not figure out why and by how much. Well, there is also this statement in the Apple docs not to manipulate the clipview directly since its main purpose is to function internally as a scrolling machine for views.
But I do know the true origin of the visible area. It’s part of the clipview’s documentVisibleRect
. So I take that origin for the calculation of the scrolled origin of the visibleRect and shift the bounds.origin
of the clipview by the same amount, and voilà: that works even if the view is getting resized.
Here is my final implementation of the new method of my NSView:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
private func scroll(toRect rect: CGRect, animationDuration duration: Double) { if let scrollView = enclosingScrollView { // we do have a scroll view let clipView = scrollView.contentView // and thats its clip view // https://stackoverflow.com/questions/19399242/soft-scroll-animation-nsscrollview-scrolltopoint // clipView.bounds.origin and clipView.documentVisibleRect.origin are the same // most of the time. When the scrollview is being resized this is not true anymore. // The y direction of bounds.origin is somehow shifted; I could not figure out why. // Apple doc say: "You don’t typically use the NSClipView class directly; it’s provided // primarily as the scrolling machinery for the NSScrollView class." // Obviously clipView.documentVisibleRect.origin reflects the true origin of the visible // region; so I use that for determining the new origin and correct that by the difference // of old bounds.origin and clipView.documentVisibleRect.origin to get the new bounds.origin. var newOrigin = clipView.documentVisibleRect.origin // make a copy of the current origin if newOrigin.x > rect.origin.x { // we are too far to the right newOrigin.x = rect.origin.x // correct that } if rect.origin.x > newOrigin.x + clipView.documentVisibleRect.width - rect.width { // we are too far to the left newOrigin.x = rect.origin.x - clipView.documentVisibleRect.width + rect.width // correct that } if newOrigin.y > rect.origin.y { // we are too low newOrigin.y = rect.origin.y // correct that } if rect.origin.y > newOrigin.y + clipView.documentVisibleRect.height - rect.height { // we are too high newOrigin.y = rect.origin.y - clipView.documentVisibleRect.height + rect.height // correct that } newOrigin.x += clipView.bounds.origin.x - clipView.documentVisibleRect.origin.x // match the new origin to bounds.origin newOrigin.y += clipView.bounds.origin.y - clipView.documentVisibleRect.origin.y NSAnimationContext.beginGrouping() // create the animation NSAnimationContext.current.duration = duration // set its duration clipView.animator().setBoundsOrigin(newOrigin) // set the new origin with animation scrollView.reflectScrolledClipView(clipView) // and inform the scroll view about that NSAnimationContext.endGrouping() // finaly do the animation } } |
BTW: the animation duration in the iOS version scrollRectToVisible
is 0.3 seconds.
Thank you 🙂 Just what I was looking for!
Consider adding
scrollView.flashScrollers()
to the end of the function.Yes, a very good idea. Thank you. I would do that if the origin actually changed (i.e. there was a scroll):