NSScrollView and manually setting the content offset while scrolling - macos

I am trying to build an endless NSScrollView, i.e. a scroll view that can scroll infinitely in either direction. This is achieved by having a scroll view with fixed dimension which is 'recentered' once it gets too close to either edge, and keeping track of an additional endless offset that is updated when recentering. Apple even demonstrated this approach in a WWDC video for iOS, if I recall correctly.
On iOS everything is working. I perform the recentering logic in -scrollViewDidScroll: and it even works when the scrolling motion is decelerating without breaking the deceleration.
Now for the Mac version. Let me tell you that I'm fairly new to Mac development, so I may simply not have performed these operations in the correct places. I currently have the recentering logic in -reflectScrolledClipView:. When I perform the move operation immediately, however, the scroll view jumps exactly twice as far as I want it to (in this case, to 4000). If I delay the method slightly, it works just as expected.
- (void)reflectScrolledClipView:(NSClipView *)cView
{
[self recenteringLogic];
[super reflectScrolledClipView:cView];
}
- (void)recenteringLogic
{
CGFloat offset = self.documentVisibleRect.origin.y;
if (offset > 6000) {
// This makes the scroll view jump to ~4000 instead of 5000.
[self performSelector:#selector(move) withObject:nil];
// This works, but seems wrong to me
// [self performSelector:#selector(move) withObject:nil afterDelay:0.0];
}
}
- (void)move
{
[self.documentView scrollPoint:NSMakePoint(0, 4000)];
}
Any ideas on how I could achieve the behavior I want?

Try this:
- (void)scrollWheel:(NSEvent *)event {
[super scrollWheel:event];
[self recenteringLogic];
}
- (void)recenteringLogic
{
NSRect rect = self.documentVisibleRect;
if (rect.origin.y > 6000) {
rect.origin.y = 4000;
[self.contentView setBounds:rect];
}
}
reflectScrolledClipView seemed to be clashing with scrollToPoint somehow, and it caused a stack overflows when used with the [self.contentView setBounds:rect]; method of scrolling.

I ended up working with [self performSelector:#selector(move) withObject:nil afterDelay:0.0]; and haven't encountered any serious issues with it, despite it seeming a little wrong.

Related

Resizing MTKView scales old content before redraw

I'm using a MTKView to draw Metal content. It's configured as follows:
mtkView = MTKView(frame: self.view.frame, device: device)
mtkView.colorPixelFormat = .bgra8Unorm
mtkView.delegate=self
mtkView.sampleCount=4
mtkView.isPaused=true
mtkView.enableSetNeedsDisplay=true
setFrameSize is overriden to trigger a redisplay.
Whenever the view resizes it scales its old content before it redraws everything. This gives a jittering feeling.
I tried setting the contentGravity property of the MTKView's layer to a non-resizing value, but that totally messes up the scale and position of the content. It seems MTKView doesn't want me to fiddle with that parameter.
How can I make sure that during a resize the content is always properly redrawn?
In my usage of Metal and MTKView, I tried various combinations of presentsWithTransaction and waitUntilScheduled without success. I still experienced occasional frames of stretched content in between frames of properly rendered content during live resize.
Finally, I dropped MTKView altogether and made my own NSView subclass that uses CAMetalLayer and resize looks good now (without any use of presentsWithTransaction or waitUntilScheduled). One key bit is that I needed to set the layer's autoresizingMask to get the displayLayer method to be called every frame during window resize.
Here's the header file:
#import <Cocoa/Cocoa.h>
#interface MyMTLView : NSView<CALayerDelegate>
#end
Here's the implementation:
#import <QuartzCore/CAMetalLayer.h>
#import <Metal/Metal.h>
#implementation MyMTLView
- (id)initWithFrame:(NSRect)frame
{
if (!(self = [super initWithFrame:frame])) {
return self;
}
// We want to be backed by a CAMetalLayer.
self.wantsLayer = YES;
// We want to redraw the layer during live window resize.
self.layerContentsRedrawPolicy = NSViewLayerContentsRedrawDuringViewResize;
// Not strictly necessary, but in case something goes wrong with live window
// resize, this layer placement makes it more obvious what's going wrong.
self.layerContentsPlacement = NSViewLayerContentsPlacementTopLeft;
return self;
}
- (CALayer*)makeBackingLayer
{
CAMetalLayer* metalLayer = [CAMetalLayer layer];
metalLayer.device = MTLCreateSystemDefaultDevice();
metalLayer.delegate = self;
// *Both* of these properties are crucial to getting displayLayer to be
// called during live window resize.
metalLayer.autoresizingMask = kCALayerHeightSizable | kCALayerWidthSizable;
metalLayer.needsDisplayOnBoundsChange = YES;
return metalLayer;
}
- (CAMetalLayer*)metalLayer
{
return (CAMetalLayer*)self.layer;
}
- (void)setFrameSize:(NSSize)newSize
{
[super setFrameSize:newSize];
self.metalLayer.drawableSize = newSize;
}
- (void)displayLayer:(CALayer*)layer
{
// Do drawing with Metal.
}
#end
For reference, I do all my Metal drawing in MTKView's drawRect method.
I have the same problem with glitches on view resizing. You can even reproduce it in the HelloTriangle example from the Apple's developer site. However the effect is minimized because the triangle is drawn near the middle of the screen, and it's the content closest to the edge of the window, opposite the corner that drags, that is effected worst. The developer notes regarding use of presentsWithTransaction and waitUntilScheduled do not work for me either.
My solution was to add a Metal layer beneath the window.contentView.layer, and to make that layer large enough that it rarely needs to be resized. The reason this works is that, unlike the window.contentView.layer, which sizes itself automatically to the view (in turn maintaining the window size), you have explicit control of the sublayer size. This eliminates the flickering.
This helped me - https://github.com/trishume/MetalTest
He uses MetalLayer and careful setting of various properties. Everything is pretty smooth even with two side by side in synchronised scroll views with 45megapixel images.
A link to my original problem How do I position an image correctly in MTKView?

Layer backed NSView problems in viewDidLoad

I set off to transform an NSImageView. My initially attempt was
self.imageView.wantsLayer = YES;
self.imageView.layer.transform = CATransform3DMakeRotation(1,1,1,1);
Unfortunately I noticed that the transform only happens sometimes (maybe once every 5 runs). Adding an NSLog between confirmed that on some runs self.imageView.layer is null. State of the whole project is shown on the image below.
It's an incredibly simple 200x200 NSImageView with an outlet to a generated NSViewController. Some experimentation showed settings wantsDisplay doesn't fix the problem, but putting the transform on an NSTimer makes it work every-time. I'd love an explanation why this happens (I presume it's due to some race condition).
I'm using Xcode 8 on the macOS 10.12 but I doubt this is the cause of the problem.
Update
Removing wantsLayer and madly enabling Core Animation Layers in Interface Builder did not fix the problem.
Neither did attempts to animate it (I wasn't sure what I was hoping for)
// Sometimes works.. doesn't animate
[NSAnimationContext runAnimationGroup:^(NSAnimationContext *context) {
context.duration = 1;
self.imageView.animator.layer.transform = CATransform3DMakeRotation(1,1,1,1);
} completionHandler:^{
NSLog(#"Done");
}];
or
// Animates but only sometimes
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:#"transform"];
animation.toValue = [NSValue valueWithCATransform3D:CATransform3DMakeRotation(1,1,1,1)];
animation.duration = 1;
[self.imageView.layer addAnimation:animation forKey:nil];
After experimenting with allowsImplicitAnimation I realised I might be trying to animate too early.
Moving the transform code into viewDidAppear made it work every time.
- (void)viewDidAppear {
[super viewDidAppear];
self.imageView.animator.layer.transform = CATransform3DMakeRotation(1,1,1,1);
}

UICollectionView iOS8 Wrong Offset

Surprisingly, after updating to iOS8 my app does not behave as in iOS7.
In particular, I made a calendar with UICollectionView. In iOS7 fine, the month cells were displayed correctly. But in iOS8...
I see an offset toward the top, that's the cells are shifted upward...I do not understand, really.... The code is very simple.
- (void)viewDidLoad
{
[super viewDidLoad];
UINib * nib = [UINib nibWithNibName:#"AgendaYearCollectionCell" bundle:[NSBundle mainBundle]];
[self.collectionView registerNib:nib forCellWithReuseIdentifier:agendaYearCellIdentifier];
[self.collectionView setDelegate:self];
[self.collectionView setDataSource:self];
UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init];
layout.minimumInteritemSpacing = 0;
layout.minimumLineSpacing = 1;
[self.collectionView setCollectionViewLayout:layout];
}
The collection was not scrollable. If I make it scrollable, I can scroll and see the cells of the first row. But I do not want a scrollable collection.
I think the problem is in the inset. In fact, if I play with:
- (UIEdgeInsets)collectionView:
(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout insetForSectionAtIndex:(NSInteger)section {
return UIEdgeInsetsMake(40, 2, 50, 60);
}
I can eventually get the first row again. But how to calculate the value for all the screen/devices? My app is landscape only and only for iPad.
What Apple changed?
Your Collection View has negative top offset. Providing more code could help, however let's try this first.
You called:
- (UIEdgeInsets)collectionView:(UICollectionView *)collectionView
layout:(UICollectionViewLayout *)collectionViewLayout
insetForSectionAtIndex:(NSInteger)section
I would not call this unless you want different insets for different sections. It looks like you have only one section. I would configure it with Flow Layout:
flowLayout.sectionInset = CGSizeMake(40.0, 2.0, 50.0, 60.0);
I recommend to drop iOS 7 support, all devices with iOS 7 can be upgraded to 8.
how to calculate the value for all the screen/devices?
Should be the same offset for any iPad. Even if Apple will ship iPad with bigger screen, your offset will be the same. If you want Universal app with support for iPhone you could put device condition check and assign different numbers depending on the device paradigm.
Also, it could be something on the top. Did you messed with Navigation Bar? It doesn't clear from the picture.

AV Foundation: Difference between currentItem being ready to play, and -[AVPlayer readyForDisplay] property?

I'm running into a weird situation with my video player, the core code of which hasn't changed much from what worked in an earlier app I made. Here's the problem: I'm inserting a "_loadingLayer" (a CATextLayer that says the video is loading), and then observing the AVPlayer's currentItem's status property to figure out when to remove the "_loadingLayer" and replace it with my actual "_playerLayer". Here's my KVO code for that:
-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if ((object == _playerLayer) && (_playerLayer.player.currentItem.status == AVPlayerItemStatusReadyToPlay)) {
[CATransaction setAnimationDuration:1.8];
_loadingLayer.opaque = NO;
if (_playerLayer.readyForDisplay) {
NSLog(#"Should be ready now.");
}
[self addPlayerLayerToLayerTree];
}
}
My problem is that the video is starting, but only the audio is playing -- the layer stays black. When I inserted the NSLog statement above, I found out why: Apparently although the currentItem's status is "AVPlayerItemStatusReadyToPlay", the player layer isn't actually readyForDisplay. This makes no sense to me -- it seems counterintuitive. Can someone please give me some guidance on this?
I was able to verify that _playerLayer is being added to the layer tree by setting its background color to red.
One other weird thing that I think might be related.... I've been seeing these messages in the debugger console:
PSsetwindowlevel, error setting window level (1000)
CGSSetIgnoresCycle: error 1000 setting or clearing window tags
Thanks in advance. This is a crosspost from the Apple Dev Forums.
We had a similar problem and traced it to what I believe is a bug in iOS 5.1 (and maybe earlier versions). It is fixed in iOS 6.0. Since I couldn't find a solution to this anywhere, I'm writing a long writeup for future people that have this problem.
If the AVPlayerItem reports a status of AVPlayerStatusReadyToPlay before the AVPlayerLayer has been obtained then the AVPlayer will never report that it is readyForDisplay.
So when you do:
self.player = [AVPlayer playerWithPlayerItem:self.playerItem];
make sure that it's followed with:
self.playerLayer = [AVPlayerLayer playerLayerWithPlayer:self.player];
and that you don't have much if any code in between the two.
I built a test rig to make it work 100% of the time or fail 100% of the time. Note that it can be tricky to see what's going on in your actual app since you will have different load times on the video and that will affect how quickly the playerItem reports AVPlayerStatusReadyToPlay.
If you want to test in your app, put this into a simple view. The below will not work (i.e. you'll hear audio but not see video) on iOS 5.1. If you switch loadPlayerLayer to instead be invoked at the end of loadPlayer, it will always work.
A follow on for future readers: A couple of player events can switch up this order and make you think it's working. They're red herrings though since they're inadvertently reversing the load order such that playerLayer is grabbed before AVStatusReadyToPlay. The events are: seeking the video, going to the home screen and then reactivating the app, the player switching to a different video/audio track inside an HLS video. These actions trigger AVStatusReadyToPlay again and thus make the playerLayer happen before AVStatusReadyToPlay.
Here's the test harness that uses Apple's test HLS video:
-(void)loadPlayer
{
NSLog(#"loadPlayer invoked");
NSURL *url = [NSURL URLWithString:#"https://devimages.apple.com.edgekey.net/resources/http-streaming/examples/bipbop_4x3/bipbop_4x3_variant.m3u8"];
self.playerItem = [AVPlayerItem playerItemWithURL:url];
[self.playerItem addObserver:self forKeyPath:#"status" options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew context:&kPlayerContext];
self.player = [AVPlayer playerWithPlayerItem:self.playerItem];
}
-(void)loadPlayerLayer
{
NSLog(#"starting player layer");
self.playerLayer = [AVPlayerLayer playerLayerWithPlayer:self.player];
[self.playerLayer addObserver:self forKeyPath:#"readyForDisplay" options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew context:&kPlayerLayerContext];
[self.playerLayer setFrame:[[self view] bounds]];
[[[self view] layer] addSublayer:self.playerLayer];
}
-(void)observeValueForKeyPath:(NSString*)path ofObject:(id)object change:(NSDictionary*)change context:(void*) context
{
if(context == &kPlayerContext){
if([self.player status] == AVPlayerStatusReadyToPlay){
NSLog(#"Player is ready to play");
//Robert: Never works if after AVPlayerItem reports AVPlayerStatusReadyToPlay
if(!self.startedPlayerLayer){
self.startedPlayerLayer = YES;
[self loadPlayerLayer];
}
}
}
if(context == &kPlayerLayerContext){
if([self.playerLayer isReadyForDisplay] == YES){
NSLog(#"PlayerLayer says it's ready to display now");
[self playTheVideoIfReady];
}
}
}

How to detect when a user, and only a user scrolls an NSScrollView

Does anyone know a way to detect when an NSScrollView is scrolled by user input, and only user input)?
The reason I want to do this is because I have a NSScrollView with a contentView that is continuously increasing it's width. I want the NSScrollView to 'lock' onto the right hand end of the contentView (i.e. track it) if the user scrolls to the right hand end of the contentView and I want the 'lock' to be released when the user (and only the scrolls) scrolls aways from the right hand end.
The closest I had to getting to this to work was by observing the NSViewBoundsDidChangeNotification and changing a 'lock' variable, as shown here:
- (void)drawRect:(NSRect)dirtyRect
{
(...)
if (lockToEnd) {
NSLog(#"xAxisView at end");
NSPoint newScrollOrigin;
newScrollOrigin.y = 0;
newScrollOrigin.x = [self frame].size.width - [[self enclosingScrollView] bounds].size.width;
[self scrollPoint:newScrollOrigin];
}
}
-(void)SWXAxisViewDidScroll:(NSNotification *)note{
NSLog(#"XAxisDidScroll: %#",note);
if ([[[self enclosingScrollView] horizontalScroller] floatValue] > 0.97){
lockToEnd = YES;
} else {
lockToEnd = NO;
}
}
However, this was not appropriate because an NSViewBoundsDidChangeNotification is sent anytime the bounds are changed, and thus when the bounds of the contentView increase, the NSScroller reduces it's floatValue and my observing method is called. EVen if I set the NSScroller's floatValue to 1.0, it is reset to 0.0 when the bounds.size.width of the contentView first exceeds the bounds.size.width of the NSScrollView. Thus, I can't tell if the NSViewBoundsDidChangeNotification was sent because the user scrolled or because the contentView got wider.
I have considered subclassing NSScroller and using the mouseDown: and mouseDragged: methods to track user input and update my lock variable. However, my concern is that these methods will not be called if the user swipes their trackpad to scroll. Another smaller concern, which I think is probably unfounded, is that it might break the NSScrollView<->NSScroller relationship and I would have to re-implement a lot of scrolling features.
Have I missed a simpler way to do this? It seems like I should be able to do this because documents do it all the time? Are my concerns about subclassing NSScroller valid?

Resources