synchronize two NSScrollView
Asked Answered
E

3

6

I read the document Synchronizing Scroll Views, and did exactly as the document, but there is an isssue.

I want to synchronize a NSTableView and a NSTextView. first let NSTableView monitor NSTextView, and everything is ok when I scroll the TextView, but when I try to scroll TableView, I found that the TableView will jump to another place(maybe backward several rows) at first, then continue to scroll from that place.

This issue still exists even after I let TextView monitor TableView.

anyone know what's the problem? can't I synchronize a TableView and a TextView?

Edited: OK, now I found that the TableView will go back to the place since last scrolling. for example, TableView's top row is 10th row, then I scroll TextView, now TableView's top row is 20th row, and if I scroll TableView again, the TableView will go back to 10th row first, then start to scroll.

Elephus answered 6/7, 2011 at 12:44 Comment(0)
S
3

I just ran into this exact problem while troubleshooting a very similar situation (on Lion). I noticed that this only occurs when the scrollers are hidden -- but I verified that they still exist in the nib, and are still instantiated correctly.

I even made sure to call -[NSScrollView reflectScrolledClipView:], but it didn't make a difference. It really seems like this is a bug in NSScrollView.

Anyway, I was able to work around the issue by creating a custom scroller class. All I had to do was override the following class methods:

+ (BOOL)isCompatibleWithOverlayScrollers
{
    // Let this scroller sit on top of the content view, rather than next to it.
    return YES;
}

- (void)setHidden:(BOOL)flag
{
    // Ugly hack: make sure we are always hidden.
    [super setHidden:YES];
}

Then, I allowed the scrollers to be "visible" in Interface Builder. Since they hide themselves, however, they do no appear onscreen and they can't be clicked by the user. It's surprising that the IB setting and the hidden property are not equivalent, but it seems clear from the behavior that they are not.

This isn't the best solution, but it's the simplest workaround I've come up with (so far).

Sporophyll answered 18/10, 2012 at 17:57 Comment(1)
Thank you for this, I ran into a similar issue where I was trying to hide the overlay scroller but it would break two-finger gesture scrolling in the scroll view. Using this method instead of [scrollView setHasVerticalScroller:NO] achieved the desired result of hiding the scroller, but having the scrollView still scroll properly.Electroscope
S
0

I had a quite similar problem. I have 3 scrollviews to synchronize. One that is a header that only scrolls horizontally. One that is a side bar that only scrolls vertically. One that is a content area below the header and to the right of the side bar. The header and side bar should move with the content area. The content area should move with the header or the side bar if either is scrolled.

Horizontal scrolling was never a problem. Vertical scrolling was always causing the two views to scroll opposite directions.

The odd resolution I came to was to create a clipView subclass (which I already did, as you pretty much always need to if you want anything nice that doesn't come out of the box.) In the clipView subclass, I add a property BOOL isInverted and in the override of isFlipped I return self.isInverted.

The weird thing is that these BOOL values for flippedness are set and match in all 3 views from the beginning. It seems that scrolling machinery is indeed buggy. My workaround that I stumbled upon was to sandwich the scroll synching code between calls to set both the side bar and content view unflipped and then update any vertical scrolling, then set both flipped again. Must be some aging code in the scrolling machinery trying to support inverted scrolling...

These are the methods called by the NSNotificationCenter addObserver methods to observe the NSViewBoundsDidChangeNotification for the clipViews.

- (void)synchWithVerticalControlClipView:(NSNotification *)aNotification
{
    NSPoint mouseInWindow = self.view.window.currentEvent.locationInWindow;
    NSPoint converted = [self.verticalControl.enclosingScrollView convertPoint:mouseInWindow fromView:nil];

    if (!NSPointInRect(converted, self.verticalControl.enclosingScrollView.bounds)) {
        return;
    }

    [self.contentGridClipView setIsInverted:NO];
    [self.verticalControlClipView setIsInverted:NO];

        // ONLY update the contentGrid view.
    NSLog(@"%@", NSStringFromSelector(_cmd));
    NSPoint changedBoundsOrigin = self.verticalControlClipView.documentVisibleRect.origin;

    NSPoint currentOffset = self.contentGridClipView.bounds.origin;
    NSPoint newOffset = currentOffset;

    newOffset.y = changedBoundsOrigin.y;

    NSLog(@"\n changedBoundsOrigin=%@\n  currentOffset=%@\n newOffset=%@", NSStringFromPoint(changedBoundsOrigin), NSStringFromPoint(currentOffset), NSStringFromPoint(newOffset));

    [self.contentGridClipView scrollToPoint:newOffset];
    [self.contentGridClipView.enclosingScrollView reflectScrolledClipView:self.contentGridClipView];

    [self.contentGridClipView setIsInverted:YES];
    [self.verticalControlClipView setIsInverted:YES];
}

- (void)synchWithContentGridClipView:(NSNotification *)aNotification
{
    NSPoint mouseInWindow = self.view.window.currentEvent.locationInWindow;
    NSPoint converted = [self.contentGridView.enclosingScrollView convertPoint:mouseInWindow fromView:nil];

    if (!NSPointInRect(converted, self.contentGridView.enclosingScrollView.bounds)) {
        return;
    }

    [self.contentGridClipView setIsInverted:NO];
    [self.verticalControlClipView setIsInverted:NO];

        // Update BOTH the control views.
    NSLog(@"%@", NSStringFromSelector(_cmd));
    NSPoint changedBoundsOrigin = self.contentGridClipView.documentVisibleRect.origin;

    NSPoint currentHOffset = self.horizontalControlClipView.documentVisibleRect.origin;
    NSPoint currentVOffset = self.verticalControlClipView.documentVisibleRect.origin;

    NSPoint newHOffset, newVOffset;
    newHOffset = currentHOffset;
    newVOffset = currentVOffset;

    newHOffset.x = changedBoundsOrigin.x;
    newVOffset.y = changedBoundsOrigin.y;

    [self.horizontalControlClipView scrollToPoint:newHOffset];
    [self.verticalControlClipView scrollToPoint:newVOffset];

    [self.horizontalControlClipView.enclosingScrollView reflectScrolledClipView:self.horizontalControlClipView];
    [self.verticalControlClipView.enclosingScrollView reflectScrolledClipView:self.verticalControlClipView];

    [self.contentGridClipView setIsInverted:YES];
    [self.verticalControlClipView setIsInverted:YES];
}

This works 99% of the time, with only occasional jitter. Horizontal scroll synch has no problems.

Simarouba answered 3/2, 2015 at 1:17 Comment(0)
S
0

Swift 4 version which uses document view in auto-layout environment. Based on Apple article Synchronizing Scroll Views with the difference that NSView.boundsDidChangeNotification temporary ignored on clip view when synchronising to other scroll view. To hide vertical scroller reusable type InvisibleScroller is used.

File SynchronedScrollViewController.swift – View controllers with two scroll views.

class SynchronedScrollViewController: ViewController {

   private lazy var leftView = TestView().autolayoutView()
   private lazy var rightView = TestView().autolayoutView()

   private lazy var leftScrollView = ScrollView(horizontallyScrolledDocumentView: leftView).autolayoutView()
   private lazy var rightScrollView = ScrollView(horizontallyScrolledDocumentView: rightView).autolayoutView()

   override func setupUI() {
      view.addSubviews(leftScrollView, rightScrollView)

      leftView.backgroundColor = .red
      rightView.backgroundColor = .blue
      contentView.backgroundColor = .green

      leftScrollView.verticalScroller = InvisibleScroller()

      leftView.setIntrinsicContentSize(CGSize(intrinsicHeight: 720)) // Some fake height
      rightView.setIntrinsicContentSize(CGSize(intrinsicHeight: 720)) // Some fake height
   }

   override func setupHandlers() {
      (leftScrollView.contentView as? ClipView)?.onBoundsDidChange = { [weak self] in
         print("\(Date().timeIntervalSinceReferenceDate) : Left scroll view changed")
         self?.syncScrollViews(origin: $0)
      }
      (rightScrollView.contentView as? ClipView)?.onBoundsDidChange = { [weak self] in
         print("\(Date().timeIntervalSinceReferenceDate) : Right scroll view changed.")
         self?.syncScrollViews(origin: $0)
      }
   }

   override func setupLayout() {
      LayoutConstraint.pin(to: .vertically, leftScrollView, rightScrollView).activate()
      LayoutConstraint.withFormat("|[*(==40)]-[*]|", leftScrollView, rightScrollView).activate()
   }

   private func syncScrollViews(origin: NSClipView) {
      // See also:
      // https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/NSScrollViewGuide/Articles/SynchroScroll.html
      let changedBoundsOrigin = origin.documentVisibleRect.origin
      let targetScrollView = leftScrollView.contentView == origin ? rightScrollView : leftScrollView
      let curOffset = targetScrollView.contentView.bounds.origin
      var newOffset = curOffset
      newOffset.y = changedBoundsOrigin.y
      if curOffset != changedBoundsOrigin {
         (targetScrollView.contentView as? ClipView)?.scroll(newOffset, shouldNotifyBoundsChange: false)
         targetScrollView.reflectScrolledClipView(targetScrollView.contentView)
      }
   }
}

File: TestView.swift – Test view. Draws line every 20 points.

class TestView: View {

   override init() {
      super.init()
      setIsFlipped(true)
   }

   override func setupLayout() {
      needsDisplay = true
   }

   required init?(coder decoder: NSCoder) {
      fatalError()
   }

   override func draw(_ dirtyRect: NSRect) {
      super.draw(dirtyRect)

      guard let context = NSGraphicsContext.current else {
         return
      }
      context.saveGraphicsState()

      let cgContext = context.cgContext
      cgContext.setStrokeColor(NSColor.white.cgColor)

      for x in stride(from: CGFloat(20), through: bounds.height, by: 20) {
         cgContext.addLines(between: [CGPoint(x: 0, y: x), CGPoint(x: bounds.width, y: x)])
         NSString(string: "\(Int(x))").draw(at: CGPoint(x: 0, y: x), withAttributes: nil)
      }

      cgContext.strokePath()

      context.restoreGraphicsState()
   }

}

File: NSScrollView.swift - Reusable extension.

extension NSScrollView {

   public convenience init(documentView view: NSView) {
      let frame = CGRect(dimension: 10) // Some dummy non zero value
      self.init(frame: frame)
      let clipView = ClipView(frame: frame)
      clipView.documentView = view
      clipView.autoresizingMask = [.height, .width]
      contentView = clipView

      view.frame = frame
      view.translatesAutoresizingMaskIntoConstraints = true
      view.autoresizingMask = [.width, .height]
   }

   public convenience init(horizontallyScrolledDocumentView view: NSView) {
      self.init(documentView: view)

      contentView.setIsFlipped(true)
      view.translatesAutoresizingMaskIntoConstraints = false
      LayoutConstraint.pin(in: contentView, to: .horizontally, view).activate()
      view.topAnchor.constraint(equalTo: contentView.topAnchor).activate()

      hasVerticalScroller = true // Without this scroll might not work properly. Seems Apple bug.
   }
}

File: InvisibleScroller.swift - Reusable invisible scroller.

// Disabling scroll view indicators.
// See: https://mcmap.net/q/861651/-hide-scrollers-while-leaving-scrolling-itself-enabled-in-nsscrollview
public class InvisibleScroller: Scroller {

   public override class var isCompatibleWithOverlayScrollers: Bool {
      return true
   }

   public override class func scrollerWidth(for controlSize: NSControl.ControlSize, scrollerStyle: NSScroller.Style) -> CGFloat {
      return CGFloat.leastNormalMagnitude // Dimension of scroller is equal to `FLT_MIN`
   }

   public override func setupUI() {
      // Below assignments not really needed, but why not.
      scrollerStyle = .overlay
      alphaValue = 0
   }
}

File: ClipView.swift - Customized subclass of NSClipView.

open class ClipView: NSClipView {

   public var onBoundsDidChange: ((NSClipView) -> Void)? {
      didSet {
         setupBoundsChangeObserver()
      }
   }

   private var boundsChangeObserver: NotificationObserver?

   private var mIsFlipped: Bool?

   open override var isFlipped: Bool {
      return mIsFlipped ?? super.isFlipped
   }

   // MARK: -

   public func setIsFlipped(_ value: Bool?) {
      mIsFlipped = value
   }

   open func scroll(_ point: NSPoint, shouldNotifyBoundsChange: Bool) {
      if shouldNotifyBoundsChange {
         scroll(to: point)
      } else {
         boundsChangeObserver?.isActive = false
         scroll(to: point)
         boundsChangeObserver?.isActive = true
      }
   }

   // MARK: - Private

   private func setupBoundsChangeObserver() {
      postsBoundsChangedNotifications = onBoundsDidChange != nil
      boundsChangeObserver = nil
      if postsBoundsChangedNotifications {
         boundsChangeObserver = NotificationObserver(name: NSView.boundsDidChangeNotification, object: self) { [weak self] _ in
            guard let this = self else { return }
            self?.onBoundsDidChange?(this)
         }
      }
   }
}

File: NotificationObserver.swift – Reusable Notification observer.

public class NotificationObserver: NSObject {

   public typealias Handler = ((Foundation.Notification) -> Void)

   private var notificationObserver: NSObjectProtocol!
   private let notificationObject: Any?

   public var handler: Handler?
   public var isActive: Bool = true
   public private(set) var notificationName: NSNotification.Name

   public init(name: NSNotification.Name, object: Any? = nil, queue: OperationQueue = .main, handler: Handler? = nil) {
      notificationName = name
      notificationObject = object
      self.handler = handler
      super.init()
      notificationObserver = NotificationCenter.default.addObserver(forName: name, object: object, queue: queue) { [weak self] in
         guard let this = self else { return }
         if this.isActive {
            self?.handler?($0)
         }
      }
   }

   deinit {
      NotificationCenter.default.removeObserver(notificationObserver, name: notificationName, object: notificationObject)
   }
}

Result:

Synchronised Scroll Views

Supporting answered 24/2, 2019 at 16:50 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.