Search code examples
objective-ccocoansviewnstextview

Keeping the contents of a scaled NSScrollView centered and visible when resizing the window


I am trying to magnify an NSScrollView which contains NSTextView and keep it centered to its content at all times. The NSTextView has left/right insets to keep the word wrapping consistent and to keep the paragraphs nicely at the center of the view.

Both [NSScrollView scaleUnitSquareToSize:...] and setMagnification:... have their own quirks and problems, but for now setMagnification seems a better option, as it is not relative.

Here's what happens (among other strange stuff): enter image description here

On resizing, I update the insets:

CGFloat inset = self.textScrollView.frame.size.width / 2 - _documentWidth / 2;
self.textView.textContainerInset = NSMakeSize(inset, TEXT_INSET_TOP);
self.textView.textContainer.size = NSMakeSize(_documentWidth, self.textView.textContainer.size.height);

Zooming in:

CGFloat magnification = [self.textScrollView magnification];
NSPoint center = NSMakePoint(self.textScrollView.frame.size.width / 2, self.textScrollView.frame.size.height / 2);

if (zoomIn) magnification += .05; else magnification -= .05;
[self.textScrollView setMagnification:magnification centeredAtPoint:center];

Everything kind of works for a while. Sometimes, depending on from which window corner the window is resized, the ScrollView loses its center, and I haven't found a solution for re-centering the view of a magnified NSScrollView.

After magnification, layout constraints can get broken too when resizing the window, especially when the textContainer is clipped out of view, and the app crashes with the following error: *** Assertion failure in -[NSISLinearExpression addVariable:coefficient:], /Library/Caches/com.apple.xbs/Sources/Foundation/Foundation-1349.91/Layout.subproj/IncrementalSimplex/NSISLinearExpression.m:716

One problem might be that I am setting the insets according to UIScrollView frame size, because the contained NSTextView's coordinates don't seem to be relative but absolute after magnification.

Is there any safe way to magnifying this sort of view and keeping it centered to its content at all times? And why are my constraints breaking?


Solution

  • I've run into similar problems, and unfortunately I ended up doing the centering myself. Here are some of the highlights of my solution.

    1. needs recursion prevention! (otherwise stackoverflow :)
    2. create a non-drawable NSView as the documentView, and then add your drawable view as a subview which is centered manually, and manually set the frame to the visibleRect of the parent.
    3. override visibleRect, call it a second time if its invalid, and debug to make sure it is valid!
    4. zooming layered backed views sux. You could try using an NSTiledLayer, but I've tried and abandoned that solution multiple times.

    Code below:

    @interface FlippedParentView : NSView
    @end
    
    @implementation FlippedParentView
    - (BOOL) isFlipped { return YES; }
    @end
    
    
    
    
    - (void)awakeFromNib
    {
        [self resetMouseInfo];
        [[self window] setAcceptsMouseMovedEvents:YES];
        needsFullRedraw = YES;
        [self setAcceptsTouchEvents:YES];
    
        // problem: when zoomed-in, CALayer backed NSOpenGLView becomes too large
        // and hurts performance.
        // solution: create a fullsizeView for the NSScrollView to resize,
        // and make NSOpenGLView a subview.  Keep NSOpenGLView size the same as visibleRect,
        // positioning it as needed on the fullsizeView.
        NSScrollView *scrollvw = [self enclosingScrollView];
        [scrollvw setBackgroundColor:[NSColor darkStrokeColor]];
        fullsizeView = [[FlippedParentView alloc] initWithFrame: [self frame]];
        [scrollvw setDocumentView:fullsizeView];
        [fullsizeView setAutoresizesSubviews:NO];
        //printf("mask %d\n", [self autoresizingMask]);
        [fullsizeView setAutoresizingMask: NSViewHeightSizable | NSViewWidthSizable | NSViewMinYMargin | NSViewMaxYMargin | NSViewMaxXMargin | NSViewMinXMargin];
        [self setAutoresizingMask: NSViewNotSizable];
        [fullsizeView addSubview:self];
    }
    
    - (NSRect) visibleRect
    {
        NSRect visRect = [super visibleRect];
        if ( visRect.size.width == 0 )
        {
            visRect = [[self superview] visibleRect];
            if ( visRect.size.width == 0 )
            {
                // this jacks up everything
                DUMP( @"bad visibleRect" );
            }
            visRect.origin = NSZeroPoint;
        }
        return visRect;
    }
    
    - (void) _my_zoom: (double)newZoom
    {
        mouseFocusPt = [self focusPt];
        NSRect oldVisRect = [[self superview] visibleRect];
        if ( newZoom < 1.0 )
            newZoom = 1.0;
        if ( newZoom > kZoomFactorMax ) newZoom = kZoomFactorMax;
    
        float xpct = (mouseFocusPt.x - oldVisRect.origin.x) /
        ( NSMaxX(oldVisRect) - oldVisRect.origin.x );
    
        float ypct = (mouseFocusPt.y  - oldVisRect.origin.y) /
        ( NSMaxY(oldVisRect) - oldVisRect.origin.y );
    
        float oldZoom = zoomFactor;
    
        zoomFactor = newZoom;
    
        /////////////////////////////////////////////////////////////////////////////////////////////////////
        // Stay locked on users' relative mouse location, so user can zoom in and back out without
        // the view scrolling out from under the mouse location.
        NSPoint newFocusPt = NSMakePoint (mouseFocusPt.x * newZoom/oldZoom,
                                          mouseFocusPt.y * newZoom/oldZoom) ;
    
        NSRect myFrame = fullsizeFrame; // [self frame];
        float marginPercent = (myFrame.size.height - drawableSizeWithMargins.height) / drawableSizeWithMargins.height;
    
        [self updateContext];
    
        NSRect newVisRect;
        newVisRect.size = [self visibleRect].size;
        newVisRect.origin.x = (newFocusPt.x) - (xpct * newVisRect.size.width);
        //DLog( @"xpct %0.2f, zoomFactor %0.2f, newVisRect.origin.x %0.2f", xpct, zoomFactor, newVisRect.origin.x);
    
        myFrame = fullsizeFrame; // [self frame];
        float marginPercent2 = (myFrame.size.height - drawableSizeWithMargins.height) / drawableSizeWithMargins.height;
        float marginDiff = (marginPercent - marginPercent2) * drawableSizeWithMargins.height;
        newVisRect.origin.y = (newFocusPt.y ) - (ypct * newVisRect.size.height) - marginDiff;
        //DLog( @"ypct %0.2f, zoomFactor %0.2f, newVisRect.origin.y %0.2f", ypct, zoomFactor, newVisRect.origin.y);
        //DLog( @"marginPercent %0.2f newVisRect %@", marginPercent, NSStringFromRect(newVisRect) );
        if ( newVisRect.origin.x < 1 ) newVisRect.origin.x = 1;
        if ( newVisRect.origin.y < 1 ) newVisRect.origin.y = 1;
    
    
         //   NSLog( @"zoom scrollRectToVisible %@ bounds %@", NSStringFromRect(newVisRect), NSStringFromRect([[self superview] bounds]) );
        // if ( iUseMousePt || isSlider )
            [[self superview] scrollRectToVisible:newVisRect];
    }
    
    // - zoomFactor of 1.0 is defined as the zoomFactor needed to show entire selected context within visibleRect,
    //   including margins of 5% of the context size
    // - zoomFactor > 1.0 will make pixels look bigger (view a subsection of a larger total drawableSize)
    // - zoomFactor < 1.0 will make pixels look smaller (selectedContext size will be less than drawableSize)
    -(void)updateContext
    {
        static BOOL sRecursing = NO;
        if ( sRecursing ) return; // prevent recursion
        sRecursing = YES;
    
        //NSRect scrollRect = [[self superview]  frame];
        NSRect clipViewRect = [[[self enclosingScrollView] contentView] frame];
        NSRect visRect = [[self superview] visibleRect]; // careful... visibleRect is sometimes NSZeroRect
    
        float layoutWidth = clipViewRect.size.width;
        float layoutHeight = clipViewRect.size.height;
    
    
    
        marginPct = layoutHeight / (layoutHeight - (overlayViewMargin*2) );
    
        // Satisfy the constraints fully-zoomed-out case:
        //  1) the drawable rect is centered in the view with at margins.
        //     Allow for 5% margins (1.025 = 2.5% left, right, top, bottom)
        //  2) guarantee the drawable rect does not overlap the mini-map in upper right corner.
        NSRect baseRect = NSZeroRect;
        baseRect.size = visRect.size;
        NSRect drawableBaseRect = getCenteredRectFloat(baseRect, metaUnionRect.size );
    
        //drawableSizeWithMargins = nsIntegralSize( nsScaleSize( drawableBaseRect.size, zoomFactor ) );
        drawableSizeWithMargins = nsScaleSize( drawableBaseRect.size, zoomFactor );
    
        // drawableSize will NOT include the margins.  We loop until we've satisfied
        // the constraints above.
        drawableSize = drawableSizeWithMargins;
    
        do
        {
            NSSize shrunkSize;
            shrunkSize.width = layoutWidth / marginPct;
            shrunkSize.height = layoutHeight /  marginPct;
            //drawableSize = nsIntegralSize( nsScaleSize( drawableBaseRect.size, zoomFactor / marginPct ));
            drawableSize = nsScaleSize( drawableBaseRect.size, zoomFactor / marginPct );
    
            [self calculateMiniMapRect]; // get approx. size.  Will calculate once more below.
    
            NSRect shrunkRect = getCenteredRectNoScaling(baseRect, shrunkSize );
    
            // DLog( @"rough miniMapRect %@ shrunk %@", NSStringFromRect(miniMapRect), NSStringFromRect(shrunkRect));
    
            // make sure minimap doesn't overlap drawable when you scroll to top-left
            NSRect topMiniMapRect = miniMapRect;
            topMiniMapRect.origin.x -= visRect.origin.x;
            topMiniMapRect.origin.y = 0;
            if ( !NSIntersectsRect( topMiniMapRect, shrunkRect ) )
            {
                topMarginPercent = fabs(shrunkRect.origin.y - drawableBaseRect.origin.y)  / baseRect.size.height;
                break;
            }
    
            float topMarginOffset = shrunkRect.size.height + (baseRect.size.height * 0.025);
            shrunkRect.origin.y = NSMaxY(baseRect) - topMarginOffset;
    
            if ( !NSIntersectsRect( topMiniMapRect, shrunkRect ) )
            {
                topMarginPercent = fabs(shrunkRect.origin.y - drawableBaseRect.origin.y)  / baseRect.size.height;
                break;
            }
    
            marginPct *= 1.025;
        } while (1);
    
        fullsizeFrame.origin = NSZeroPoint;
        fullsizeFrame.size.width  = fmax(drawableSizeWithMargins.width, layoutWidth);
        fullsizeFrame.size.height = fmax(drawableSizeWithMargins.height, layoutHeight);
    
        [fullsizeView setFrame:fullsizeFrame];
    
        NSRect myNewFrame = [fullsizeView visibleRect];
        if (myNewFrame.size.width > 0)
           [self setFrame: myNewFrame]; //NSView
    
        sRecursing = NO;
    }