实现Facebook的重力感应看图片功能

原文地址:http://subjc.com/facebook-paper-photo-panner

github地址:https://github.com/subjc/SubjectiveCPhotoPanner

上面的gif本来是想上传视频上去的,但是发现上传后并不能播放。大家仔细看就发现,手机里的图片随着手机的倾斜而转动,这个就是问题所说的那个功能。

分析

移动部分

当我们设想,如果没有把全景照片和移动联系到一起时,我们可以把全景照片分解为一个个小的视图来展示图片(UIImageView),和一个能够让这些分解开的视图链接起来的视图(UIScorllView)以及一个展示scroll bar的图层(CASharpLayer)。

我们可以通过UIScrollView的缩放功能,让全景图片在我们手中的装置上以充满屏幕的方式显示。我们只需要基于视频和图片的比例来确定合适的缩放等级即可。

有了这些要素,我们可以搭建出更多的设置全景图片的功能了。但为了实现图片随着设备的旋转而旋转,我们还需要调用更多的设备传感器。

设备旋转

最新一代的iOS设备配有很多组用来用来计量或者维持某个状态的装置。在这里我们主要是使用陀螺仪这个装置。

A gyroscope is a device for measuring or maintaining orientation, based on the principles of angular momentum

大概意思就是:陀螺仪是基于角动量原理用来计量或者维持某个状态的装置。

当陀螺仪转动被获取时,我们便可以使用CMMotionManager类(一个与运动传感器交互的类)。CMMotionManager允许你更好地获得或者更改(使用block)物理监控仪器的数据。他还提供一种更新设备数据的功能,该功能包含手持的姿势、旋转角度和设备的加速度。我们在回调时也可以使用相同的数据,它产生的偏差已经被Core Motion算法移除了。

为了开始接受设备的回调数据,我们调用方法startDeviceMotionUpdatesToQueue:withHandler: ,并且传递一个NSOperationQueue和一个相应的块来更改数据。

1
2
3
4
[self.motionManager startDeviceMotionUpdatesToQueue:[NSOperationQueue mainQueue]
withHandler:^(CMDeviceMotion *motion, NSError *error) {
// Do our processing
}];

现在当设备旋转时,我们可以接受来自CMMotionManager的回调了,但是我们需要将陀螺仪传回来的数据翻译成一种我们的UIScrollView’s contentOffset可以接受的东西。

我们最关心的数据莫过于rotationRate了,我们能通过它获得设备的x、y、z轴分别旋转的角度。

1
2
3
CGFloat xRotationRate = motion.rotationRate.x; 
CGFloat yRotationRate = motion.rotationRate.y;
CGFloat zRotationRate = motion.rotationRate.z;

通常上,我们通过yRotationRate来确认设备是否旋转,当然有时也会用xRotationRate 和 zRotationRate。

但是有时我们可能获得不到yRotationRate的数据,我们会使用xRotationRate 和 zRotationRate来做yRotationRate的下限。

1
2
3
if (fabs(yRotationRate) > (fabs(xRotationRate) + fabs(zRotationRate))) {
// Do our movement
}

当然如果成功获取到yRotationRate的数据那必定是比xRotationRate 和 zRotationRate的和好的,这样我们确定旋转是有意义的,并且也和我们相应的UIScrollView相适合。

我们需要把设备的动作翻译成旋转的角度。同时我们要把展示的图片缩放到合适的比例,我们也要把角度同样改变到相应的比例。

1
2
3
4
5
static CGFloat kRotationMultiplier = 5.f; 
CGFloat invertedYRotationRate = yRotationRate * -1;
CGFloat zoomScale = (CGRectGetHeight(self.panningScrollView.bounds) / CGRectGetWidth(self.panningScrollView.bounds)) * (image.size.width / image.size.height);
CGFloat interpretedXOffset = self.panningScrollView.contentOffset.x + (invertedYRotationRate * zoomScale * kRotationMultiplier);
CGPoint contentOffset = [self clampedContentOffsetForHorizontalOffset:interpretedXOffset];

方法 clampedContentOffsetForHorizontalOffset 的返回值是CGPoint。就是将一个水平参数返回成UIScrollView可以接受的垂直参数并且限制该参数不越界。

1
2
3
4
5
6
7
- (CGPoint)clampedContentOffsetForHorizontalOffset:(CGFloat)horizontalOffset; { 
CGFloat maximumXOffset = self.panningScrollView.contentSize.width - CGRectGetWidth(self.panningScrollView.bounds);
CGFloat minimumXOffset = 0.f;
CGFloat clampedXOffset = fmaxf(minimumXOffset, fmin(horizontalOffset, maximumXOffset));
CGFloat centeredY = (self.panningScrollView.contentSize.height / 2.f) - (CGRectGetHeight(self.panningScrollView.bounds)) / 2.f;
return CGPointMake(clampedXOffset, centeredY);
}

流畅性

该方法就如同小标题所说的一样

1
2
3
4
5
6
7
8
9
static CGFloat kMovementSmoothing = 0.3f;
[UIView animateWithDuration:kMovementSmoothing
delay:0.0f
options:UIViewAnimationOptionBeginFromCurrentState|
UIViewAnimationOptionAllowUserInteraction|
UIViewAnimationOptionCurveEaseOut
animations:^{
[self.panningScrollView setContentOffset:contentOffset animated:NO];
} completion:NULL];

现在我们可以编译运行我们的程序了,你会发现,图片的旋转更加流畅也更加自然。

添加Scroll bar

为了使用Scroll bar,我们需要使用 CAShapeLayer、strokeStart和strokeEnd的特性来确定Scroll bar的长度和位置。我们不必使用它们具体的特性,我们只需要用contentOffset of the UIScrollView using CADisplayLink的一个方法让他固定就好了。

A CADisplayLink object is a timer object that allows your application to synchronize its drawing to the refresh rate of the display.

我们使用 CADisplayLink的对象来保证我们展示一个同步的回调,该回调可以获得我们的UIScrollView的正确的位置并且合适的适应我们的scroll bar。使用CADisplayLink的好处是提供了一个为这类操作而准备的NSTimer,使我们可以校准在展示过程中的微小差异。

建立一个 CADisplayLink 对象的作用和NSTimer差不多;我们创建CADisplayLink,新建一个目标回调来激发屏幕的每次刷新并将它添加到进程中。

1
2
3
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkUpdate:)];

[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];

你可能注意到我们把我们的CADisplayLink对象添加到了NSRunLoopCommonModes的进程中。只要它没有没覆盖,我们还可以支持触摸踪迹来代替在UITrackingRunLoopMode里面的UIScrollView的踪迹。如果在这种情况下我们想要调用NSDefaultRunLoopMode,我们在触摸scroll bar时就会失去视觉的反馈,就是没有响应。

接下来我们开始设置图层

1
2
3
4
CALayer *panningImageViewPresentationLayer = self.panningImageView.layer.presentationLayer;
CALayer *panningScrollViewPresentationLayer = self.panningScrollView.layer.presentationLayer;
CGFloat horizontalContentOffset = CGRectGetMinX(panningScrollViewPresentationLayer.bounds);
CGFloat contentWidth = CGRectGetWidth(panningImageViewPresentationLayer.frame);

一旦我们设置好了这些,就要开始计算我们的UIScrollView的宽度了。

1
2
3
4
CGFloat visibleWidth = CGRectGetWidth(self.panningScrollView.bounds); 
CGFloat clampedXOffsetAsPercentage = fmax(0.f, fmin(1.f, horizontalContentOffset / (contentWidth - visibleWidth)));
CGFloat scrollBarWidthPercentage = visibleWidth / contentWidth;
CGFloat scrollableAreaPercentage = 1.0 - scrollBarWidthPercentage;

现在我们剩下的就是通过CAShapeLayer来传递这些值了,时时从我们的CADisplayLink获取回调

1
2
3
4
- (void)updateWithScrollAmount:(CGFloat)scrollAmount forScrollableWidth:(CGFloat)scrollableWidth inScrollableArea:(CGFloat)scrollableArea { 
self.scrollBarLayer.strokeStart = scrollAmount * scrollableArea;
self.scrollBarLayer.strokeEnd = (scrollAmount * scrollableArea) + scrollableWidth;
}

到了这里我们还需要在做一件事情。我们的scroll bar并没有在 previous post中声明,但是许多的layer都提供 implicit animation,其可使我们通过提供CABasicAnimations方便的加载动画。

这种情况下,可能会导致我们的scroll bar在UIScrollView的动画完成后加载。

为了解决这种情况,我们需要从 CAShapeLayer中移除strokeStart and strokeEnd

1
self.scrollBarLayer.actions = @{@"strokeStart": [NSNull null], @"strokeEnd": [NSNull null]};

结尾

这篇文章是我尝试翻译的第一篇文章,若有哪里翻译错误了请向我的邮箱发送邮件,谢谢了。