Discrete (row-by-row) scrolling in NSTextView - objective-c

How can I implement row-by-row scrolling in an NSTextView object? It has to work regardless of how the user tries to scroll: either if they drag the scroll knob or use a two-finger scroll gesture on the trackpad. Furthermore, the scroll knob should always move in discrete steps. (This is exactly the way Apples terminal emulator works.)
What I tried to do first was to subclass NSTextView and override adjustScroll: (which it inherits from NSView), which is described here, that is:
- (NSRect)adjustScroll:(NSRect)newVisible {
NSRect modifiedVisible = newVisible;
NSFont *font = [self font];
CGFloat lineHeight = [[self layoutManager] defaultLineHeightForFont:font];
modifiedVisible.origin.x = (int)(modifiedVisible.origin.x/lineHeight) * lineHeight;
modifiedVisible.origin.y = (int)(modifiedVisible.origin.y/lineHeight) * lineHeight;
return modifiedVisible;
}
This however only gives row-by-row scrolling when I use the scroll knob. By accident I found out that subclassing NSScrollView would give me row-by-row scrolling also when scrolling with the trackpad, even though it seems I'm not really doing anything (so why this works is a mystery), the only overridden methods are these:
- (instancetype)initWithFrame:(NSRect)frame {
self = [super initWithFrame:frame];
return self;
}
- (void)drawRect:(NSRect)dirtyRect {
[super drawRect:dirtyRect];
}
The scroll knob, however, still moves continuously. Do I have to subclass NSScroller and override setDoubleValue (I tried doing that, but it didn't work very well so that solution feels difficult, though maybe possible) or can I override reflectScrolledClipView: in my subclass to NSScrollView (that didn't work at all), or are there better solutions?

Related

NSScrollView: fade in a top-border like Messages.app

What I Want to Do:
In Messages.app on OS 10.10, when you scroll the left-most pane (the list of conversations) upwards, a nice horizontal line fades in over about 0.5 seconds. When you scroll back down, the line fades back out.
What I Have:
I am trying to achieve this effect in my own app and I've gotten very close. I subclassed NSScrollView and have done the following:
- (void) awakeFromNib
{
_topBorderLayer = [[CALayer alloc] init];
CGColorRef bgColor = CGColorCreateGenericGray(0.8, 1.0f);
_topBorderLayer.backgroundColor = bgColor;
CGColorRelease(bgColor);
_topBorderLayer.frame = CGRectMake(0.0f, 0.0f, self.bounds.size.width, 1.0f);
_topBorderLayer.autoresizingMask = kCALayerWidthSizable;
_topBorderLayer.zPosition = 1000000000;
_fadeInAnimation = [[CABasicAnimation animationWithKeyPath:#"opacity"] retain];
_fadeInAnimation.duration = 0.6f;
_fadeInAnimation.fromValue = #0;
_fadeInAnimation.toValue = #1;
_fadeInAnimation.removedOnCompletion = YES;
_fadeInAnimation.fillMode = kCAFillModeBoth;
[self.layer insertSublayer:_topBorderLayer atIndex:0];
}
- (void) layoutSublayersOfLayer:(CALayer *)layer
{
NSPoint origin = [self.contentView documentVisibleRect].origin;
// 10 is a fudge factor for blank space above first row's actual content
if (origin.y > 10)
{
if (!_topBorderIsShowing)
{
_topBorderIsShowing = YES;
[_topBorderLayer addAnimation:_fadeInAnimation forKey:nil];
_topBorderLayer.opacity = 1.0f;
}
}
else
{
if (!_topBorderIsShowing)
{
_topBorderIsShowing = NO;
// Fade out animation here; omitted for brevity
}
}
}
The Problem
The "border" sublayer that I add is not drawing over top of all other content in the ScrollView, so that we end up with this:
The frames around the image, textfield and checkbox in this row of my outlineView are "overdrawing" my border layer.
What Causes This
I THINK this is because the scrollView is contained inside an NSVisualEffectView that has Vibrancy enabled. The reason I think this is that if I change the color of my "border" sublayer to 100% black, this issue disappears. Likewise, if I turn on "Reduce Transparency" in OS X's System Preferences > Accessibility, the issue disappears.
I think the Vibrancy compositing is taking my grey border sublayer and the layers that represent each of those components in the outlineView row and mucking up the colors.
So... how do I stop that for a single layer? I've tried all sorts of things to overcome this. I feel like I'm 99% of the way to a solid implementation, but can't fix this last issue. Can anyone help?
NB:
I am aware that it's dangerous to muck directly with layers in a layer-backed environment. Apple's docs make it clear that we can't change certain properties of a view's layer if we're using layer-backing. However: adding and removing sublayers (as I am) is not a prohibited action.
Update:
This answer, while it works, causes problems if you're using AutoLayout. You'll start to get warnings that the scrollView still needs update after calling Layout because something dirtied the layout in the middle of updating. I have not been able to find a workaround for that, yet.
Original solution:
Easiest way to fix the problem is just to inset the contentView by the height of the border sublayer with this:
- (void) tile
{
id contentView = [self contentView];
[super tile];
[contentView setFrame:NSInsetRect([contentView frame], 0.0, 1.0)];
}
Should have thought of it hours ago. Works great. I'll leave the question for anyone who might be looking to implement these nice fading-borders.

Why NSView leaves an image on superview on where it was when I move it?

I am working on a small application on Mac that I need to create customed cursor and move it. I used NSImageView to implement it. However when I call setFrameOrigin (the same to setFrame) it will leaves images on the previous place.
Here is my code:
#property (nonatomic, strong) NSImageView *eraserView;
this is the define
_eraserView = [[NSImageView alloc] initWithFrame:CGRectMake(100, 100, 32, 32)];
_eraserView.image = [NSImage imageNamed:#"EraserCursor"];
[self.view addSubview:_eraserView];
[_eraserView setHidden:YES];
here is the initialization. Everything goes well until now but:
- (void)setImageatPoint:(NSPoint)point
{
[_eraserView setFrameOrigin:point];
}
- (void)hidePenImage
{
[_eraserView setHidden:YES];
}
- (void)unhidePenImage: (BOOL)isEraser
{
[_eraserView setHidden:NO];
}
These are methods I use to change the state of the NSImageView. They will be called by another class using delegate when corresponding events of trackpad occurs.
However every time I change the state of the NSImageView, it seems like it is drawn on the superview.
I debugged it and found there was no extra subviews. And when I use setHidden it has no effect on those tracks. I think it somehow did something to the CALayer, but I have no idea how to fix it.
Screenshots would help but in general if you move a view or change the area of the view that is drawn, you need to redraw.
To do this it kind of depends on how your drawing happens. Calling setNeedsDisplay may not be enough if your implementation of drawRect only draws a sub rect of the view bounds. Cocoa only draws what it is told to draw.
You can erase sections of the view that should be empty by drawing (filling) where it should be empty. That means drawing a color ( NSColor clearColor if nothing else) in the area that was previously drawn.

UIDynamics and Device Rotation

If I add any kind of UIDynamicBehavior to my views, it completely breaks things when the device is rotated. Here's what it is in portrait (displaying correctly):
And here it is in landscape, all broke:
I don't believe it's an autolayout issue because if I remove the calls to add the UIDynamicBehavior it works fine with no autolayout problems. No autolayout errors are ever thrown either. Here's the code:
#interface SWViewController () {
UICollisionBehavior *coll;
UIDynamicAnimator *dynamicAnimator;
}
#implementation SWViewController
- (void)viewDidLoad {
[super viewDidLoad];
dynamicAnimator = [[UIDynamicAnimator alloc] initWithReferenceView:self.view];
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
[self setupCollisions]; // commenting this out fixes the layout
}
- (void)setupCollisions {
NSArray *dynamicViews = #[greenView];
coll = [[UICollisionBehavior alloc] initWithItems:dynamicViews];
CGFloat topBound = CGRectGetMinY(greenView.frame);
[coll addBoundaryWithIdentifier:#"top"
fromPoint:CGPointMake(0, h1)
toPoint:CGPointMake(CGRectGetWidth(greenView.frame), h1)];
[dynamicAnimator addBehavior:coll];
}
If I override didRotateFromInterfaceOrientation I can see that the top boundary of greenView doesn't follow what autolayout says it should (again, removing the call to setupCollisions fixes this).
The autolayout boundaries on greenView are:
height = 200
trailing space to Superview = 0
leading space to Superview = 0
bottom space to Superview = 0
One solution I found was to override willRotateToInterfaceOrientation: and remove the UIDynamicItems and then re-add them in didRotateFromInterfaceOrientation:. This strikes me as rather hacky, and could potentially introduce bugs once I add more complex behaviors.
Dynamic animator is changing frames of involved views. As a result any animation would be disturbed by a call to -[UIView setNeedsLayout] (views would be put to constraint driven positions regardless on dynamic animation state.
My observation is that is you use auto-generated layout constraints the dynamic animator removes them from any view involved in the animation.
If you add your own layout constraints - they persist - but can disturb your animation when view is asked to recalculate layout.
Please double check your auto layout constraints.
I had a very similar problem:
In my case, I have a subview, MyView, which had a set of constraints: V:|-(0)-[MyView], V:[MyView]-(0)-|, H:[MyView:(==300)], and it works good with out UIDynamics. But after adding UIDynamics to MyView, the width changed during rotation, which is very similar to your problem.
I fixed it by adding one more constraint: H:|-(0)-[MyView]

iCarousel and UITextView -- blank text view

I'm using iCarousel to display editable question cards. The cards contain a UITextView for entering the question (or already contain text as you swipe through filled cards). However, when the carousel is presented and scrolled, sometimes text views appear empty.
This is due to a UITextView optimization of not drawing text offscreen. But text views in a UITableView will not suffer from this.
As many know, using setNeedsDisplay will NOT work due to the optimization, so it doesn't redraw the text.
I currently change the text view's frame by adding and then removing 1px. This forces a redraw. However, I can only do this when the item changes. iCarousel does not have a willDisplayCell delegate method. (Nick, can you add one easily? The code baffles me)
Because iCarousel is preloading many views for smoothness (which is necessary, setting iCarouselOptionVisibleItems doesn't fix anything) there doesn't seem to be anything else I can do but know when the view is about to come on screen. Suggestions?
- (UIView *)carousel:(iCarousel *)carousel viewForItemAtIndex:(NSUInteger)index reusingView:(UIView *)view
{
MIQuestionView *questionView = (MIQuestionView *)view;
if (questionView == nil)
{
MIQuestionType type = [self.testSection.questionType integerValue];
questionView = [[MIQuestionView alloc] initWithFrame:CGRectNull questionType:type];
questionView.delegate = self;
}
questionView.question = [self.testSection.questions objectAtIndex:index];
return questionView;
}
The text view is embedded in the MIQuestionView. The text is set in the question setter. There's no way for me to know when it's coming onto the screen. To be clear, I don't want to resize. The text is not drawn offscreen and appear blank when coming on-screen.
Sorry, I didn't look for ambiguous code above:
- (id)initWithFrame:(CGRect)frame questionType:(MIQuestionType)type
{
if (CGRectEqualToRect(frame, CGRectNull))
frame = CGRectMake(0, 0, 650, 244);
self = [super initWithFrame:frame];
...
It's an odd bug. I'm not sure if it's an issue with iCarousel or just a quirk of iOS that you need to deal with when dynamically adding and removing UITextViews from the view hierarchy.
I have a solution that's maybe a bit cleaner than the ones you've found though; Just add this to your MICardView:
- (void)didMoveToSuperview
{
if (self.superview)
{
_textView.frame = self.bounds;
}
}
This basically forces the textView to re-layout every time the cardView is recycled, and it avoids you having to do anything special in your view controller to work around the issue.

Cursor rect on top of NSTextView

I have implemented an iOS style NSScrollView by adding a knob subview to the NSScrollView. The implementation works just fine except for one thing - the cursor change over the knob view.
I am using NSViews -resetCursorRects method to setup the cursor rectangle.
- (void)resetCursorRects {
[self addCursorRect:self.knobFrame cursor:[NSCursor pointingHandCursor]];
}
This works too, but the Cursor is immediately reset to the IBeam style as soon as I cross the cursor rect boundary.
How can I prevent this? Has it something to do with the knob view not being opaque?
UPDATE 0:
I also tried to implement it with a normal NSScroller and forcing the scroller to overlay the NSClipView in the scroll view's -tile method, but it seems regardless of what view is placed on top of the NSTextView it always enforces the IBeam cursor type.
UPDATE 1:
I found this mailing list entry that suggests overriding the NSTextViews -mouseMoved, but since this is not a satisfying solution for a robust custom NSScrollView implementation its not really an option.
Setting up an NSTrackingArea in the NSScroller with the NSTrackingMouseMoved option set and implementing
- (void)mouseMoved:(NSEvent *)theEvent {
[[NSCursor pointingHandCursor] set];
}
does the trick.