Prisma--成功的不只是艺术

高仿Prisma自定义相机拍照、相册图片选取、图片编辑功能

Posted by Roylee on 2016-08-03

Logo

前言

前一阵子,都说东半球的人都在忙着抓妖怪,西半球的人都在忙着搞艺术照。对,这后者就是很是流行的Prisma。Prisma的流行,说明大家心中还是保留着对艺术的追求。

而我,也按耐不住,赶紧尝试了一番。由于自己的一点警戒心里,所以猜测Prisma会不会把图片上传到服务器呢?于是,就做了一次断网测试。果真,报了断网错误而且图片一直处于上传中。所以,可以肯定的是:Prisma的图片处理是放在服务端的。不出所料,之后经常会出现服务器过载的问题。但是看着Prisma处理好的图片,真是佩服不已(其实很是希望他们的图片处理是放在前端,也好逆向一下,哈哈)。

对于Prisma的图片艺术处理,只能是叹为观止。但是,仍然按耐不住想要重写一下Prisma的冲动。于是,重写了Prisma所有的界面功能。

在重写的中发现,Prisma的优秀并不仅仅只是对于图片的艺术处理。作为一个开发者,自然而然就会关注它的每个细节 ——真是细思极恐啊,Prisma在细节的处理上十分的认真。界面简洁易用,图片的操作细节到位。也因此,遇到了很多坑。接下来就整理一下重写过程中涉及到的问题与坑。

Prisma的构建是采用StoryBoard与xib,语言是Swift。因此,重写的项目也是采用Swift结合StroyBoard、xib


项目架构

StoryBoard

通过分析Prisma的界面构造,以及Playload中的文件。个人认为,Prisma的布局是采用如下方式:

  • RootViewController作为跟视图控制器,相机窗口以及图片编辑图片艺术合成窗口都放在屏幕的上方,采用1 :1的比例设置
  • 由于Prisma底部操作界面看上去十分像一个导航控制器,于是大胆的采用导航控制器作为底部操作界面。——这样,在界面跳转上方便不少,但是也存在一些坑,接下来会具体的分析。

那么,接下来就来各个击破,解决每个问题。


自定义相机

先贴上Prisma所用的Frameworks:

Frameworks

从上图中可以看出Prisma中使用的框架有哪些。我们可以看到,Prisma采用的图像采集框架是AVFoundation。而自定义相机也正是使用的此框架中的:

  • AVCaptureSession 相机的会话管理类,负责视频图片的管理功能
  • AVCaptureDeviceInput 图像采集输入设备
  • AVCaptureStillImageOutput 照片流输出
  • AVCaptureVideoPreviewLayer 图像预览图层,用于实时呈现摄像头采集景象


设备初始化

现在,在声明相关属性之后,开始初始化相机相关

func initAVCapture() {
    // Device
    let device = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo)

    try! device.lockForConfiguration()
    if device.hasFlash {
        device.flashMode = AVCaptureFlashMode.Off
    }
    if device.isFocusModeSupported(.AutoFocus) {
        device.focusMode = .AutoFocus
    }
    if device.isWhiteBalanceModeSupported(.AutoWhiteBalance) {
        device.whiteBalanceMode = .AutoWhiteBalance
    }
    if device.exposurePointOfInterestSupported {
        device.exposureMode = .ContinuousAutoExposure
    }
    device.unlockForConfiguration()

    // Input & Output
    // When init AVCaptureDeviceInput first, system will show alert to confirm the authentication from user.
    // But the best way is send the acces request manual. see `requestAccessForMediaType`
    deviceIntput = try! AVCaptureDeviceInput(device: device)
    stillImageOutPut = AVCaptureStillImageOutput()

    // Output settings
    stillImageOutPut?.outputSettings = [AVVideoCodecKey:AVVideoCodecJPEG, AVVideoScalingModeKey:AVVideoScalingModeResize]

    if session.canAddInput(deviceIntput) {
        session.addInput(deviceIntput)
    }
    if session.canAddOutput(stillImageOutPut) {
        session.addOutput(stillImageOutPut)
    }

    session.sessionPreset = AVCaptureSessionPresetPhoto
    // Preview
    previewLayer = AVCaptureVideoPreviewLayer.init(session: session)
    previewLayer?.videoGravity = AVLayerVideoGravityResizeAspectFill

    // Set root vc avcapture preview layer
    photoPisplayBoard?.setAVCapturePreviewLayer(previewLayer!)
}

这里面有几个坑值得注意:

  • 在配置AVCaptureDevice的时候,需要锁定设备,防止有其他的操作访问设备
  • AVCaptureDevicefocusMode属性有三个参数,分别是LockedAutoFocusContinuousAutoFocus。顾名思义,分别是锁定(就是只是对焦一个死的点),自动,持续自动。值得注意的是,自动模式只是在切换设备的时候自动对焦一次;持续对焦才会在设备移动后根据相位检测持续对焦
  • AVCaptureStillImageOutput需要配置参数outputSettings,设置输出配置为JPEG
  • 最后一个就是AVCaptureVideoPreviewLayer预览图层了,预览图层是有自己的frame的,并不是设定的layer的frame。因此,我们需要设置填充模式,这里采用AVLayerVideoGravityResizeAspectFill,就是左右填充对齐(实际上上下多出来的被cliptobounds了)

最后,只要开启相机就行了

override func viewWillAppear(animated: Bool) {
    session.startRunning()
}

这里会涉及到一个权限认证的问题:

当然在我们第一次,是第一次哦,初始化AVCaptureDeviceInput的时候,系统会弹出一次权限许可的alert,因此第一次可以不用做认证。但是这个权限弹框只有一次,假如用户拒绝了,第二次进入后我们再初始化就会导致崩溃。因此,最好的方式,还是主动做一次权限认证判断

/// Capture authorization
class func captureAuthorization(shouldCapture: ((Bool)-> Void)!) {

    let captureStatus = AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo)
    switch captureStatus {
    case.NotDetermined:
        AVCaptureDevice.requestAccessForMediaType(AVMediaTypeVideo, completionHandler: { (granted:  Bool) -> Void in
            shouldCapture(granted)
        })
        break
    case.Authorized:
        shouldCapture(true)
        break
    default:
        shouldCapture(false)
        break
    }
}
摄像头切换
@IBAction func changeFlash(sender: AnyObject) {
    var image: UIImage? = nil

    let device = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo)
    try! device.lockForConfiguration()
    if device.hasFlash {
        switch device.flashMode {
        case .Off:
            device.flashMode = .On
            currentFlashMode = .On
            image = UIImage.init(named: "flash-on")
            break
        case .On:
            device.flashMode = .Auto
            currentFlashMode = .Auto
            image = UIImage.init(named: "flash-auto")
            break
        case .Auto:
            device.flashMode = .Off
            currentFlashMode = .Off
            image = UIImage.init(named: "flash")
            break
        }
        // Flash baritem
        let letButton = navigationBar.topItem!.leftBarButtonItem?.customView as? UIButton
        letButton?.setImage(image, forState: UIControlState.Normal)
    }
    device.unlockForConfiguration()
}

这里同样要锁定设备再进行操作。接下来细节来了:

  • 我本以为,flash什么的操作按钮直接是UIBarButtonItem就可以了。但是,由于前置摄像头不支持flash,所以,在前置转台下Prisma会将flash按钮置灰。而UIBarButtonItem无法通过同一张图片(对,就是同一张图片,Prisma只是用了一张一个状态的图片)来设置enabled的置灰装填。因此这里,我采用一个button作为leftBarButtonItem的customView,便可以实现了
  • 另一个问题是,UIBarButtonItem会在渲染图片的时候,根据本身的tintColor对图片处理,不能保证原图。所以,我们设置的图片需要做一次render处理 image = image?.imageWithRenderingMode(UIImageRenderingMode.AlwaysOriginal)。当然,最简单的模式是在Assets.xcassets中对图片渲染模式设置,如图:

RenderImage

手动对焦
// Tap header to focus
func tapToChangeFocus(tap: UITapGestureRecognizer) {
    guard !isUsingFrontFacingCamera else {
        return
    }
    // Location
    let point = tap.locationInView(photoPisplayBoard?.displayHeaderView)

    // Show square
    showSquareBox(point)

    // Change focus
    //        let pointInCamera = convertToPointOfInterestFromViewCoordinates(point)
    let pointInCamera = previewLayer!.captureDevicePointOfInterestForPoint(point)
    let device = deviceIntput?.device
    try! device!.lockForConfiguration()

    if device!.focusPointOfInterestSupported {
        device!.focusPointOfInterest = pointInCamera
    }
    if device!.isFocusModeSupported(.ContinuousAutoFocus) {
        device!.focusMode = .ContinuousAutoFocus
    }
    if device!.exposurePointOfInterestSupported {
        device?.exposureMode = .ContinuousAutoExposure
        device?.exposurePointOfInterest = pointInCamera
    }
    device?.subjectAreaChangeMonitoringEnabled = true
    device!.focusPointOfInterest = pointInCamera

    device!.unlockForConfiguration()
}

手动对焦,就是通过点击设置设备的感兴趣的点,同时,增加一个方框的动画。下面是坑:

  • 由于相机的位置关系,当我们正常竖着放置手机的时候,其实,相机是侧立的。因为,iPhone的相机默认是横屏home键在右为正常位置,就像一个真正的相机的位置一样。而这一点,在后面还会用到处理图片方向上
  • 设备的感兴趣的点的坐标是从左上角的{0,0}到右下角的{1,1}。而由于设备相机方向问题,竖屏时,右上角就是相机坐标系的左上角

我们可以通过计算将点击屏幕的点转化为相机坐标系的点,然而幸运的是,系统提供了一个方法captureDevicePointOfInterestForPoint()可以直接帮我们转化

另外,关于相机设备方向的资料可以参考如何处理iOS中照片的方向

捕获图片

重头戏,也是坑最大的地方。怎样捕获相机的图片并输出

@IBAction func capturePhoto(sender: AnyObject) {

    // Disable the capture button
    captureButton.enabled = false

    let stillImageConnection = stillImageOutPut?.connectionWithMediaType(AVMediaTypeVideo)
    // let curDeviceOrientation = UIDevice.currentDevice().orientation
    // let avCaptureOrientation = FMDeviceOrientation.avOrientationFromDeviceOrientation(curDeviceOrientation)
    let avCaptureOrientation = AVCaptureVideoOrientation(rawValue: UIDevice.currentDevice().orientation.
    if stillImageConnection!.supportsVideoOrientation {
        stillImageConnection!.videoOrientation = avCaptureOrientation
    }
    stillImageConnection!.videoScaleAndCropFactor = 1

    stillImageOutPut?.captureStillImageAsynchronouslyFromConnection(stillImageConnection, completionHandler: { (imageDataSampleBuffer: CMSampleBufferRef!, error: NSError!) in
        let jpegData = AVCaptureStillImageOutput.jpegStillImageNSDataRepresentation(imageDataSampleBuffer)

        if var image = UIImage(data: jpegData) {

            // Fix orientation & crop image
            image = image.fixOrientation()
            image = PMImageManger.cropImageAffterCapture(image,toSize: self.previewLayer!.frame.size)

            // Fix interface orientation
            if !self.orientationManger.deviceOrientationMatchesInterfaceOrientation() {
                let interfaceOrientation = self.orientationManger.orientation()
                image = image.rotateImageFromInterfaceOrientation(interfaceOrientation)
            }

            // Mirror the image
            if self.isUsingFrontFacingCamera {
                image = UIImage.init(CGImage: image.CGImage!, scale: image.scale, orientation: UIImageOrientation.UpMirrored)

                let imageV = UIImageView.init(frame: self.previewLayer!.bounds)
                imageV.image = image
                self.view.addSubview(imageV)
            }

            // Save photo
            let authorStatus = ALAssetsLibrary.authorizationStatus()
            if  authorStatus == ALAuthorizationStatus.Restricted || authorStatus == ALAuthorizationStatus.Denied {
                return
            }

            let library = ALAssetsLibrary()
            if self.isUsingFrontFacingCamera {
                let attachments = CMCopyDictionaryOfAttachments(kCFAllocatorDefault,imageDataSampleBuffer,kCMAttachmentMode_ShouldPropagate)
                //            let attachments = CMGetAttachment(imageDataSampleBuffer, kCGImagePropertyExifDictionary, nil)
                library.writeImageToSavedPhotosAlbum(image.CGImage!, metadata: attachments as? [NSObject:AnyObject] , completionBlock: { (url: NSURL!, error: NSError!) in

                })
            }else {
                library.writeImageToSavedPhotosAlbum(image.CGImage, orientation: ALAssetOrientation.UpMirrored, completionBlock: { (url: NSURL!, error: NSError!) in

                })
            }

            // Go to style vc
            self.photoPisplayBoard?.setState(.SingleShow, image: image, selectedRect: CGRectZero, zoomScale:1, animated: false)
            let storyBoard = UIStoryboard.init(name: "Main", bundle: nil)
            let styleVC = storyBoard.instantiateViewControllerWithIdentifier("styleImageController") as? PMImageProcessController
            styleVC?.fromCapture = true
            self.navigationController?.pushViewController(styleVC!, animated: true)
        }

        // Stop session
        self.session.stopRunning()
    })
}

为了保证图片的方向,我们在输出图片的时候,我们需要通知AVCaptureStillImageOutput当前设备的方向,这里获得AVCaptionConnection来设置当前设备的方向,将当前设备的方向同步到相机视频上。(有人讨论说当设备横屏的时候,要将传给相机视频的方向取相反的方向,但测试,实际上,只要传入当前设备的方向,告知相机当前设备的真正方向就可以了

这里有个大坑:由于Prisma只是支持Portrait模式,所以获取到的当前设备的方向总是Portrait的,即竖屏的。那么这时候传递给相机的后,相机认为的方向其实向右旋转了90度,因为前面我们说了,相机的正常方向是横屏向左
所以,在输出的image时,image被赋值的方向就是向右旋转90度:UIImageOrientation.Right(相机理解的方向)。此时,保存出来的图片就会是向左旋转90度的(图片要放正嘛,想象相机放正的样子)

拍照位置

根据以上的情况,我们对于刚刚捕获的图片进行orientation的处理fixOrientation(),这样图片就是我们预想中拍摄的方向了。接下来要对图片进行裁切处理

上面我们说过AVCaptureVideoPreviewLayer是有自己的size frame的,并不是我们设置的1 :1的大小,而是左右填充居中的。所以我们要对图片进行一次裁切处理。

CropImage

这样,我们就得到了我们想要拍摄的图像了。

除此之外,由于Prisma只是支持Portrait方向。所以,当我们横屏向右拍摄的时候,此时device的方向依然是Portrait(plist中设置的方向决定设备的方向),相机仍然是认为侧立向右的,这样,即便我们通过fixOrientation()恢复到正确的方向,由于横屏向右的缘故,图片就会变成向左转动90度(虽然,屏幕横屏向右,但对于相机来说任然是侧立向右。而此时,屏幕顶部闪光灯一侧的位置正好是横屏下图片的右侧,所以在正过来之后就会出现图片右侧成为图片顶部的状况

这时,我们需要通过重力感应的方式,获取屏幕界面的方向 ——是真正的屏幕界面的方向,不是UIDevice的方向

这里定义一个类FMDeviceOrientation,包含CMMotionManager属性,通过下面的方法获取方向:

private func actualDeviceOrientationFromAccelerometer() -> UIDeviceOrientation {
    let acceleration = motionManager.accelerometerData!.acceleration
    if acceleration.z < -0.75 {
        return UIDeviceOrientation.FaceUp
    }

    if acceleration.z > 0.75 {
        return UIDeviceOrientation.FaceDown
    }

    let scaling = 1.0 / (fabs(acceleration.x) + fabs(acceleration.y))

    let x = acceleration.x * scaling
    let y = acceleration.y * scaling

    if x < -0.5 {
        return UIDeviceOrientation.LandscapeLeft
    }

    if x > 0.5 {
        return UIDeviceOrientation.LandscapeRight
    }

    if y > 0.5 {
        return UIDeviceOrientation.PortraitUpsideDown
    }

    return UIDeviceOrientation.Portrait
}

这里获取的就是我们看到的界面的方向,比如,手机竖屏就是Portrait,右侧横屏就是LandScapeRight。

我们知道了当前界面的方向,就可以通过判断界面的方向与设备的方向是否一致来决定是否对图片做进一步的旋转操作。比如:

现在,右侧横屏拍摄一张图片,出来的效果是向左旋转90度。此时,我们也可以通过fixOrientation()的方式再次处理,但是需要做参数调整:

此时图片向左旋转90度,正好跟没有处理原始图片是的方向一样。相机坐标系认为竖屏是向右侧立,image的方向也是Right了,出来的图片也是向左旋转90度。所以,我们设定图片的方向参数也是Right,调用fixOrientation(imageOrientation: UIImageOrientation),这样我们假定图片是相机向右侧立拍摄,通过fix后,就会得到正常的图片。因此也得出结论:

界面的方向是哪个方向,我们再次调用fixOrientation(imageOrientation: UIImageOrientation)后,就再传入哪个方向就可以了

完整过程如下:

// Fix orientation & crop image
image = image.fixOrientation()
image = PMImageManger.cropImageAffterCapture(image,toSize: self.previewLayer!.frame.size)

// Fix interface orientation
if !self.orientationManger.deviceOrientationMatchesInterfaceOrientation() {
    let interfaceOrientation = self.orientationManger.orientation()
    image = image.rotateImageFromInterfaceOrientation(interfaceOrientation)
}

最终,我们的到了我们想要的图片。根据配置,进行是否保存相册就好了。(Prisma细节的对于拍摄按钮都做了缩放点击处理,这里对于实现过程就不做介绍了)

实现效果如下

照相效果

自定义相机参考资料iOS上相机捕获


相册选择

从上面Prisma的框架可以看出,Prisma使用的相册框架是AssetsLibrary,在iOS8之后,系统增加了相册框架Photos,因此,这里我是采用Photos框架实现相册功能

PrsimaImagePicker

这是一个独立的模块,可以单独使用。最初,打算重写Prisma也是因为喜欢上Prisma有意思的图片选择器,而决定自己也要做一个。

首先,分析Prisma图片选择器的结构:

  • 顶部是一个单独的view用于作为大图的呈现,与图片的简单缩放编辑
  • 下面是展示一个相册内的所有照片,点击后,大图切换。并且根据点击的位置与大图的位置不同,会做一个交互调整。首先保证所选图片在大图底部对齐,其次是所选图片如果遮挡会自动全部显露
  • 在交互上,底部列表单独滑动。当手势移动到大图底部边缘的时候,大图跟随滑动,从而增加可视面积

通过以上的功能分析,决定采用约束动画的方式实现大图与列表的位置关系,这样,我只需改变顶部大图的位置,就可以自然的改变底部列表的高度,来达到列表面积的变化。

另一点,通过操作可以发现,Prisma中对于底部列表与顶部大图header的手势处理是连贯的:在滑动底部列表同时滑动顶部header时,顶部header到达顶部后,底部列表仍然可以有惯性的向上滚动。所以,一开始想要使用UIDynimc的方式给列表与header添加一个共用的手势来处理,后来觉得这个很是麻烦,效果可能也会不尽人意。于是,采用了另一种方式:Method Swizzle

运行时,交换UIScrollViewPanGestureRecognizer的action方法(这个action的获得可以通过打印手势才获取),然后给UIScrollView增加一个手势滑动操作的回调。这样,就可以在UIScrollView滚动的时候同时处理顶部大图header的位置了

这里有个细节值得注意的是,由于采用约束做动画。在UIKit动作画处理的时候,不能单纯的改变约束的值,因为,这样只是通知系统,约束改变已经处于可以重新布局的状态。所以,我们要主动调用setNeedsLayout来布局UI,否则是没有动画效果的,如下:

UIView.animateWithDuration(self.constParams.backHeaderAnimationDuration, delay: 0, options: [UIViewAnimationOptions.AllowUserInteraction, UIViewAnimationOptions.CurveLinear], animations: {
     self.headerTopConstraints.constant = 0
     self.view.layoutIfNeeded()
     }, completion: { (finish: Bool) in

})

实现的效果

相册选择

这样,图片选择功能界面的重点就完成了。接下来需要处理相册了

获取相册图片

导入Photos框架,需要使用的类有:

  • PHAssetCollection 相册相关的类,处理可以根据参数获取所有的相册
  • PHAsset 保存一个相册照片信息,获取某个相册中的照片
  • PHFetchResult 处理相册照片后返回的结果,通过这个结果获取具体的相册内容或者照片内容



首先,同样需要获取系统权限的认证

/// Photolibrary authorization
class func photoAuthorization(canGoAssets: ((Bool)-> Void)!) {

    let PhotoStatus: PHAuthorizationStatus = PHPhotoLibrary.authorizationStatus()
    switch (PhotoStatus) {
    case .NotDetermined:
        PHPhotoLibrary.requestAuthorization { (status: PHAuthorizationStatus) in
            dispatch_async(dispatch_get_main_queue(), {
                switch (status) {
                case .Authorized:
                    canGoAssets(true)
                    break
                default:
                    canGoAssets(false)
                    break
                }
            })
        }
        break
    case .Authorized:
        canGoAssets(true)
        break
    default:
        canGoAssets(false)
        break
    }
}

认证之后,就获取系统所有的相册(或者我们想要获取的相册)

相册获取通过public class func fetchAssetCollectionsWithType(type: PHAssetCollectionType, subtype: PHAssetCollectionSubtype, options: PHFetchOptions?) -> PHFetchResult接口枚举返回的PHFetchResult来获取相册,其中PHAssetCollectionTypePHAssetCollectionSubtype配合可以获取我们制定的相册

var photoGroups:[PHAssetCollection] = [PHAssetCollection]()

// Camera
let cameraRoll: PHAssetCollection = (PHAssetCollection.fetchAssetCollectionsWithType(.SmartAlbum, subtype: .SmartAlbumUserLibrary, options: nil).lastObject as? PHAssetCollection)!
if cameraRoll.photosCount > 0 {
    photoGroups.append(cameraRoll)
}

// Favorites
let favorites: PHFetchResult = PHAssetCollection.fetchAssetCollectionsWithType(.SmartAlbum, subtype: .SmartAlbumFavorites, options: nil)
favorites.enumerateObjectsWithOptions(.Reverse) { (obj, index: Int, stop: UnsafeMutablePointer<ObjCBool>) in
    let collection = obj as! PHAssetCollection
    guard collection.photosCount > 0 else {
        return
    }
    photoGroups.append(collection)
}

这里需要介绍一下获取相册的类型,根据不同的类型才能获取我们想要的相册

enum PHAssetCollectionType : Int {
    case Album //从 iTunes 同步来的相册,以及用户在 Photos 中自己建立的相册
    case SmartAlbum //经由相机得来的相册
    case Moment //Photos 为我们自动生成的时间分组的相册
}

enum PHAssetCollectionSubtype : Int {
    case AlbumRegular //用户在 Photos 中创建的相册,也就是我所谓的逻辑相册
    case AlbumSyncedEvent //使用 iTunes 从 Photos 照片库或者 iPhoto 照片库同步过来的事件。然而,在iTunes 12 以及iOS 9.0 beta4上,选用该类型没法获取同步的事件相册,而必须使用AlbumSyncedAlbum。
       case AlbumSyncedFaces //使用 iTunes 从 Photos 照片库或者 iPhoto 照片库同步的人物相册。
    case AlbumSyncedAlbum //做了 AlbumSyncedEvent 应该做的事
    case AlbumImported //从相机或是外部存储导入的相册,完全没有这方面的使用经验,没法验证。
    case AlbumMyPhotoStream //用户的 iCloud 照片流
    case AlbumCloudShared //用户使用 iCloud 共享的相册
    case SmartAlbumGeneric //文档解释为非特殊类型的相册,主要包括从 iPhoto 同步过来的相册。由于本人的 iPhoto 已被 Photos 替代,无法验证。不过,在我的 iPad mini 上是无法获取的,而下面类型的相册,尽管没有包含照片或视频,但能够获取到。
    case SmartAlbumPanoramas //相机拍摄的全景照片
    case SmartAlbumVideos //相机拍摄的视频
    case SmartAlbumFavorites //收藏文件夹
    case SmartAlbumTimelapses //延时视频文件夹,同时也会出现在视频文件夹中
    case SmartAlbumAllHidden //包含隐藏照片或视频的文件夹
    case SmartAlbumRecentlyAdded //相机近期拍摄的照片或视频
    case SmartAlbumBursts //连拍模式拍摄的照片,在 iPad mini 上按住快门不放就可以了,但是照片依然没有存放在这个文件夹下,而是在相机相册里。
    case SmartAlbumSlomoVideos //Slomo 是 slow motion 的缩写,高速摄影慢动作解析,在该模式下,iOS 设备以120帧拍摄。不过我的 iPad mini 不支持,没法验证。
    case SmartAlbumUserLibrary //这个命名最神奇了,就是相机相册,所有相机拍摄的照片或视频都会出现在该相册中,而且使用其他应用保存的照片也会出现在这里。
    case Any //包含所有类型
}

注意这里的type与subtype要对应

参考文章



获取目标相册之后,就要获取某个相册下的所有照片了

照片获取需要使用PHAssetpublic class func fetchAssetsInAssetCollection(assetCollection: PHAssetCollection, options: PHFetchOptions?) -> PHFetchResult接口,同样枚举返回的PHFetchResult获取图片内容

/// Get photos from an album
class func photoAssetsForAlbum(collection: PHAssetCollection) -> [PHAsset] {
    var photoAssets:[PHAsset] = [PHAsset]()

    let asstes: PHFetchResult = PHAsset.fetchAssetsInAssetCollection(collection, options: nil)
    asstes.enumerateObjectsWithOptions(NSEnumerationOptions.Reverse) { (obj, index: Int, stop: UnsafeMutablePointer<ObjCBool>) in
        photoAssets.append(obj as! PHAsset)
    }
    return photoAssets
}

相册完成之后的效果如下

相册group

照片缩放旋转编辑

说到这里,缩放什么的只是采用UIScrollView就可以了。不过,有意思的是,Prisma真是细节做的很到位。

  • 在图片选择模式下,会有个隐藏的九宫格,拖动图片的时候格子标尺会显示。照片编辑模式下,常显示格子标尺
  • 一开始只是认为Prisma的九宫格是一个像素的白线构成,但是当我们浏览白色照片的时候,会发现,一个像素的线的底部有一个浅浅的灰色半透明的线,以便白色照片下仍可以观察。而在深色系照片上则是很难发现它的存在(细节处理真是到位)
  • 同时,无论怎样滑动图片。格子标尺总是自适应照片与屏幕的边缘,只显示在图片在屏幕中的位置

为了达到这个效果,首先需要画一个九宫格,采用自定义view重写drawRect画一像素的线

func drawLine(context: CGContext, color: UIColor, width: CGFloat) {
    let width1 = CGFloatPixelRound(bounds.size.width/3)
    let width2 = CGFloatPixelRound(bounds.size.width/3 * 2)
    let height1 = CGFloatPixelRound(bounds.size.height/3)
    let height2 = CGFloatPixelRound(bounds.size.height/3 * 2)

    // H line 1
    CGContextSetStrokeColorWithColor(context, color.CGColor)
    CGContextMoveToPoint(context, 0, height1 + lineOffset)
    CGContextAddLineToPoint(context, bounds.size.width, height1 + lineOffset)
    CGContextSetLineWidth(context, width)
    CGContextStrokePath(context)

    CGContextSaveGState(context)

    // H line 2
    CGContextSetStrokeColorWithColor(context, color.CGColor)
    CGContextMoveToPoint(context, 0, height2 + lineOffset)
    CGContextAddLineToPoint(context, bounds.size.width, height2 + lineOffset)
    CGContextSetLineWidth(context, width)
    CGContextStrokePath(context)

    CGContextRestoreGState(context)
    CGContextSaveGState(context)


    // V line 1
    CGContextSetStrokeColorWithColor(context, color.CGColor)
    CGContextMoveToPoint(context, width1 + lineOffset, 0)
    CGContextAddLineToPoint(context, width1 + lineOffset, bounds.size.height)
    CGContextSetLineWidth(context, width)
    CGContextStrokePath(context)

    CGContextRestoreGState(context)
    CGContextSaveGState(context)

    // V line 2
    CGContextSetStrokeColorWithColor(context, color.CGColor)
    CGContextMoveToPoint(context, width2 + lineOffset, 0)
    CGContextAddLineToPoint(context, width2 + lineOffset, bounds.size.height)
    CGContextSetLineWidth(context, width)
    CGContextStrokePath(context)
}

其次,格子view需要添加在ScrollView的父视图上,并观察ScrollView的contentOffset,对图片的移动进行跟踪改变格子的位置与大小。

同时,需要增加ScrollView缩放的回调,来处理缩放中格子的变化

这里一开始,把格子放在ScrollView的图片上。缩放中,图片的frame的变化并不是看上去的样子,而是根据scale做了坐标系的仿射变换,看上去大小变了,而frame是做了scale的处理的。而这样,不好处理,所以格子要加在ScrollView的父视图上

实现的效果如下

格子视图



照片的旋转就简单了,动画改变transform就可以了。不过,有一点是,在旋转编辑进入下一步的时候我们需要通过记录的图片的位置以及图片的缩放大小来对图片做一次旋转截图处理

Prisma对于选择图片后进入编辑页面的处理,也是保留了imagePicker选择图片时的缩放状态与位置(很人性)

func cropImageAffterEdit() -> UIImage {
    // Get the rect
    var imageRect = CGRectZero
    let ratio = image.size.width/imageView.contentSize.width
    var x = fmax(imageView.contentOffset.x, 0)
    var y = fmax(imageView.contentOffset.y, 0)
    x = x/imageView.contentSize.width * image.size.width
    y = y/imageView.contentSize.height * image.size.height
    imageRect = CGRectMake(x, y, bounds.size.width * ratio, bounds.size.height * ratio)

    // Crop
    var croppedImage = PMImageManger.cropImageToRect(self.image, toRect: imageRect)
    // Rotate
    let imageOrientation = PMImageManger.imageOrientationFromDegress(currentAngle)
    if imageOrientation != .Up {
        croppedImage = UIImage.init(CGImage: croppedImage.CGImage!, scale: croppedImage.scale, orientation: imageOrientation)
    }
    return croppedImage
}

实现效果如下

图片编辑

操作菜单

Prisma对于底部操作菜单的处理,通常会简单的使用UIScrollView来实现,但是在分析过程中,发现无论是操作界面顶部的按钮位置,界面转场,还是通过边缘侧滑返回上看,都觉得Prisma采用的是自定义UINavigationController的转场效果来实现的。所以,我这里也是通过自定义导航控制器实现操作界面功能

首先,自定义导航控制器,遵从UIViewControllerAnimatedTransitioningUINavigationControllerDelegate代理自定义转场效果,取消转场中的层级变化。

其次,在自定义的导航控制你中添加一个平移手势,并且取消系统边缘返回手势。达到控制手势滑动返回。

最后,由于导航控制器共用一个UINavigationBar,会有顶部菜单的过度动画。为了取消这个过度动画,影藏导航栏,给每个Controller单独添加一个UINavigationbar,这样就可以无过渡的切换Controller了(同时,这个方式还可以用来实现网易音乐等的转场效果

实现效果如下

过渡动画

通过,重写Prisma,让自己更加感受到了Prisma的魅力。这一样一个极其注重细节的公司,做出一个艺术图片处理的APP,怎么能够不活?因此,也让我更加理解注重细节的魅力。作为一个开发者,也应该要有一颗产品的心,对自己的项目负责,认真对待每一个细节(题外啰嗦了,哈哈😄)。



项目地址GitHub