Intermediate iOS Programming with Swift 笔记 Ⅲ

最近花了点时间,把 《Intermediate iOS Programming with Swift》 刷了一遍,这是第三部分

二十一、BUILDING A TODAY WIDGET

1. 介绍 app extensions

iOS 8 苹果放出了 app extensions,让你可以在你的 app 之外扩展你的 app 的功能,主要有以下几种类型的 extensions:

  • Today 显示简单信息并且能执行一些简单操作
  • Share 和其他 app 分享内容或分享到网站
  • Action 操作宿主 app 中的内容
  • Photo Editing 通过 Photos app 编辑照片或视频
  • Document Provider 提供访问和管理文件
  • Custom Keyboard 提供自定义键盘

2. 理解 app extension 如何工作

一个 extension 并不是一个单独的app,他会随 app bundle 一起打包提交到 App Store,下面是一个 weather app 的示意图: extension 和 container app 运行在不同的进程中,有着各自的沙盒,他们甚至都不能直接交流,而 container app 只能提供共享资源供 extension 使用。如果想让二者交流只能通过 openURL 和 NSUserDefaults(二者都能读写) 的方式迂回交流。

3. Tooday Extension Demo

Today extensions 作为 widgets 出现在 Notification Center 的 Today View 上,具体创建步骤:

  1. Extensions 通常和 container app 的 target 是分离的,为了方便重用,较好的做法是将通用代码放到 framework 中去,这样 container app 和 extension 都能访问。选择 iOS > Framework & Library > Cocoa Touch Framework 创建一个新的 Framework 取名 WeatherInfoKit,完成后会看到一个新的 target,如果你之前用过 Objective-C ,那么这个 frameworks 会包含所有的头文件,但 swift 不需要编辑他。

    选中新建的 targets ,为 “Deployment Info section” 下面的选项 Allow app extension API only 打勾。这里注意并不是所有的 Cocoa Touch API 都在 extensions 中能用,比如下面一些事情 extensions 就不能做:

    • 访问摄像头和麦克风
    • 通过 AirDrop 接收文件
    • 执行长时间的后台操作
    • 使用头文件中的 NSEXTENSIONUNAVAILABLE marcro
    • 访问 sharedApplication 对象,比如 HealthKit framework 和 EventKit UI framework 对 extensions 就不能用
  2. 将通用的文件移动到 Framework 中,一般是 modal 文件和 网络处理相关的文件(如解析 json)直接在 Project Navigator 上将文件拖到 WeatherInfoKit 文件夹里,需要注意的就是仅仅靠拖是不够的,要更改文件的 target 成员归属。

    Swift 有三种访问等级 public, internal and private,默认是 internal,但为了跨 target 访问,我们将这些类和属性都改成 public。

  3. 最后别忘了在 VC 中 import WeatherInfoKit

  4. 创建 Today Widget,首先 Editor > Add Target,选择 iOS > Application Extension > Today Extension,取名 Weather Widget 完成。这是会弹出窗口确认激活 Widget scheme。

    选中 Weather Widget 然后在 Linked Frameworks and Libraries 一栏增加我们第 1 步创建的 WeatherInfoKit.framework

  5. 现在我们来实现 extension,做完以上步骤,在 Xcode 左侧的 Project Navigator 的会出现一个以 widget 命名的新 group 文件夹,将会包括 extension 的 storyboard、view controller 和 property list 文件。而这个 plist 文件包含了 widget 的一些属性,你不需要编辑这个 plist,但要注意 NSExtension 这个字典,他包含了一个 NSExtensionMainStoryboard key 对应着 widget 的 storyboard name,你可以更改这个 name。
  6. 确认 Weather Widget scheme 被选中,然后运行
  7. 可以为 Weather Widget 添加 label,显示城市和天气、温度
  8. 为了启用 widget 能够在后台更新 view,我们将变化放到 widgetPerformUpdateWithCompletionHandler 这个方法之中,这个方法会被自动调用,给你机会更新 widget 的内容。

4. 通过Container app 分享数据

因为 extension 不能直接访问 container app,所以通过分享的 NSUserDefaults 进行交流。具体步骤如下:

  • 选中 main app target,选择 Capabilities tab,打开 App Groups 的开关,创建一个新的 container 并给定一个全宇宙唯一的名字,比如 group.com.appcoda.weather
  • 选中 Weather Widget target,执行上面相同步骤,但最后不要创建新的 container 了,而是选择上一步设置好的 container。

    这样两个 target 就共享这一个 group container 了。

  • 当你做完上面的步骤,启用 app groups 后,app extension 和 containing app 就都能使用 NSUserDefaults API 来交流。

    • 在container app 中定义 NSUserDefaults

          var defaults = NSUserDefaults(suiteName: "group.com.appcoda.weatherdemo")!
      
    • 用 KVC 设置传递

          defaults.setValue(selectedLocation, forKey: "location")
      
    • widget 从 NSUserDefaults 中读取

          // 同样是先定义 NSUserDefaults
          var defaults = NSUserDefaults(suiteName: "group.com.appcoda.weatherdemo")!
          // 读取
          // Get the location from defaults
          if let defaultLocation = defaults.valueForKey("location") as? String {
              location = defaultLocation 
          }
      


二十二、BUILDING A SIDEBAR MENU

主要介绍使用了第三方开源库 SWRevealViewController ,侧滑菜单的主要特定是

  • 点击 menu 菜单按钮打开抽屉
  • 向右滑动手势打开抽屉
  • 再次点击 menu 菜单关闭抽屉
  • 向左滑动手势关闭抽屉

使用 SWRevealViewController 库来实现抽屉式菜单

目前这个库是用 OC 写的,还不原生支持 swift,因此我们刚好学习一下如何在 swift 中桥接 OC 写的类。

  • 在 project navigator 上新建一个 Group,将 SWRevealViewController.h 和 SWRevealViewController.m 这两个文件拖进来,这时 Xcode 会弹出一个窗口询问你是否要 “configure an Objective- C bridging header”,接着在这个新建的 bridging 头文件中插入 #import "SWRevealViewController.h"通过创建这个 header fileSidebarMenu-Bridging-Header.h) 你可以让 Swift 访问 OC 的代码。
  • 将 Front view 和 Rear view 连接起来。SWRevealViewController 库内置支持了 Storyboard ,你实现 sidebar menu 只需将相关的 SWRevealViewController 如 front VC 和 rear VC 用 segues 联系起来。

    在 SB 中,rear VC 一般是显示导航菜单的 VC,这里的 segue 要选择 reveal view controller set segue,这是一个自定义的 SWRevealViewControllerSetSegue

    选择这个 segue 更改他的 identifiersw_rear,这样就告诉 SWRevealViewController 这个 menu view controller 是 rear view controller,在这个例子中侧边 菜单是在内容 view controller 下层的。

    继续连接 segue 到 content VC,更改 identifiersw_front,表明 content VC 是在上面的。

    如下图所示,Content View Controller 和 Menu View Controller 可以被看作都放进最开始的 Container View Controller 中,而第一个容器 VC 正是 一个 SWRevealViewController,他负责处理各种手势和滑动抽屉,你唯一要做的就是确定添加到这个容器中 VC 的层次顺序。

  • 现在实现点击 menu 菜单滑出抽屉的操作,在第一个 Content VC 中的 viewDidLoad 中添加:

    if self.revealViewController() != nil { 
        menuButton.target = self.revealViewController() 
        menuButton.action = "revealToggle:"
        self.view.addGestureRecognizer(self.revealViewController().panGestureRecognizer())
    }
    

    凡是在 SWRevealViewController 容器中的 Child VC,都可以通过 revealViewController() 这个方法来得到他们的 Parent 即(SWRevealViewController 对象),因此在 SWRevealViewController 对象上触发 revealToggle: 方法,最后一行我们添加了滑动打开抽屉的手势.

  • 现在来实现点击 menu 上的 cell push 到相应的 VC,还是使用 segue,只不过这次类型要选择 reveal view controller push controller,这也是一个自定义的 SWRevealViewControllerSeguePushController 用来处理切换不同的 VC

  • 同样的为其他的 Content VC 添加一样的代码:

    if self.revealViewController() != nil { 
        menuButton.target = self.revealViewController() 
        menuButton.action = "revealToggle:"
        self.view.addGestureRecognizer(self.revealViewController().panGestureRecognizer())
    }
    
  • 你还可以自定义 menu 按钮的宽度,其他的一些特性请参照 SWRevealViewController.h

    self.revealViewController().rearViewRevealWidth = 62
    


二十三、VIEW CONTROLLER TRANSITIONS AND ANIMATIONS

从 iOS 7 开始,苹果允许你通过 View Controller Transitioning API 实现自定义的转场动画,这里的转场主要是指:在两个 View Controller 场景间进行切换。而这个 API 给了你完全的控制权。这里有两种类型的 view controller transitions:

  • interactive 可交互的,比如从 iOS 7 开始,在 navigation controller 下的 VC 支持手势操作 push 和 pop
  • non-interactive 本章我们主要关注这种类型的过渡动画

自定义转场动画,你需要创建一个 animator 对象(导演),用来实现自定义转场。当转场动画开始时,这个 animator (导演)对象由 UIKit framework 调用,然后开始执行转场动画,而转场结束时会通知 framework。

当你实现 non-interactive 类型的转场动画时,需要处理以下协议:

  • UIViewControllerAnimatedTransitioning 你的 animator 对象必须遵守这一协议创建 VC 转场进入或离开动画,也即 “切换中应该发生什么”
  • UIViewControllerTransitioningDelegate 遵守这个协议的对象用来提供 animator objects 供 present 和 dismiss VC 时(non-interactive 主要涉及这个操作)使用。也就是说在需要 VC 切换的时候系统会向实现了这个接口的对象询问是否需要使用自定义的切换效果
  • UIViewControllerContextTransitioning 这个接口用来提供切换上下文给开发者使用,包含了从哪个VC到哪个VC等各类信息,一般不需要开发者自己实现。

Demo 实现

这个 Demo,主要实现 "Slide Down", "Slide Right", "Pop", "Rotate" 这四种切换过渡动画。

1. 创建一个 Slide Down 动画

  • 创建一个 animator 对象 SlideDownTransitionAnimator,继承自 NSObject,并遵循 UIViewControllerAnimatedTransitioningUIViewControllerTransitioningDelegate 这两个协议
  • UIViewControllerTransitioningDelegate 主要提供一个 animator 供切换时使用(因为可以有多个 animator),非交互式切换动画主要是 presentdismiss,所以你要实现下面两个方法:

    func animationControllerForPresentedController(presented: UIViewController,
                         presentingController presenting: UIViewController, 
                                 sourceController source: UIViewController) ->
                                        UIViewControllerAnimatedTransitioning? {
        return self 
    }
    
    
    func animationControllerForDismissedController(dismissed: UIViewController) ->
                                        UIViewControllerAnimatedTransitioning? {
        return self 
    }
    

    分别返回了 selfSlideDownTransitionAnimator 类作为 animator

  • UIViewControllerAnimatedTransitioning 提供了实际的动画实现,主要需要实现两个动画:

    • transitionDuration(_:) 动画持续时间
    • animateTransition(_ transitionContext: UIViewControllerContextTransitioning) 动画实现

      var duration = 0.5
      func transitionDuration(transitionContext: UIViewControllerContextTransitioning) -> NSTimeInterval { 
          return duration
      }
      func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
          // 获取到我们的 fromView, toView 和 container view
          let fromView = transitionContext.viewForKey(UITransitionContextFromViewKey)! 
          let toView = transitionContext.viewForKey(UITransitionContextToViewKey)!
          let container = transitionContext.containerView()
          // 设置好 transform 我们将在下面的动画中使用
          let offScreenUp = CGAffineTransformMakeTranslation(0, -container.frame.height) 
          let offScreenDown = CGAffineTransformMakeTranslation(0, container.frame.height)
          // 设置目标 View (toView)的初始位置 
          toView.transform = offScreenUp
          // 将 toView 和 fromView 都添加到 container view 
          container.addSubview(fromView) 
          container.addSubview(toView)
          // 执行动画
          UIView.animateWithDuration(duration, delay: 0.0, 
                      usingSpringWithDamping: 0.8, 
                       initialSpringVelocity: 0.8, 
                                     options: nil, 
                                  animations: {
              fromView.transform = offScreenDown 
              // 移除屏幕时,带透明效果
              fromView.alpha = 0.5
              toView.transform = CGAffineTransformIdentity
          }, completion: { finished in 
              // 通知系统过渡动画已经完成
              transitionContext.completeTransition(true)
          }) 
      }
      

    实现主要分三步:

    1. 获取到 fromView, toView 和 container view
    2. 将 fromView, toView 添加到 container view 中去
    3. 执行动画
  • 使用我们的自定义动画替代默认的标准过渡效果

    • 创建 SlideDownTransitionAnimator 对象

      var slideDownTransition = SlideDownTransitionAnimator()
      
    • 实现 prepareForSegue 方法

      override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
          let toViewController = segue.destinationViewController as UIViewController
          let sourceViewController = segue.sourceViewController as ViewController
          let selectedIndexPaths = sourceViewController.collectionView.indexPathsForSelectedItems() as [NSIndexPath]    
          switch selectedIndexPaths[0].row {
              case 0: toViewController.transitioningDelegate = self.slideDownTransition
              default: break
          } 
      }
      

      判断是否是第一个 cell,然后设置 toViewControllertransitioningDelegate

  • 反转整个过渡过程,我们已经实现了让 detial view 顶部向下,main view 从屏幕到底部这一过渡动画,现在需要反转这一过程,让 detial view 离开 main view 重新回到屏幕。做法也非常简单,我们只需要向 SlideDownTransitionAnimator 类增加一个 Bool 变量 isPresenting 用来追踪 当前是否 presenting 了个 VC 还是 dismiss 掉了。

    注意:present 和 dismiss 时,fromView 和 toView 表示的 Main View 和 Detail View 是刚好相反的

    • 创建 isPresenting 变量

      var isPresenting = false
      
    • 通过 animationControllerForDismissedController 和 animationControllerForPresentedController 这两个方法保持追踪:

      func animationControllerForPresentedController(presented: UIViewController,
                       presentingController presenting: UIViewController, 
                               sourceController source: UIViewController) ->
                                      UIViewControllerAnimatedTransitioning? {
          isPresenting = true
          return self 
      }
      
      
      func animationControllerForDismissedController(dismissed: UIViewController) ->
                                      UIViewControllerAnimatedTransitioning? {
          isPresenting = false
          return self 
      }
      
    • 更新 animateTransition 动画

      // 增加判断,只有 present 的时候,toView(detailView) 的初始位置才在上面
      if isPresenting {
          toView.transform = offScreenUp
      }
      
      
      // 修改实际动画        
      if self.isPresenting {
        // 将main view透明度设为 0.5
          fromView.transform = offScreenDown 
          fromView.alpha = 0.5
          toView.transform = CGAffineTransformIdentity
      } else {
          // 反转过程,将main view透明度设为 1
          fromView.transform = offScreenUp 
          toView.alpha = 1.0
          toView.transform = CGAffineTransformIdentity
      }
      

2. 创建一个 Slide Right 动画

detail view 从左侧滑入屏幕最终覆盖到 main view 上,dimiss 过程则是将 detail view 滑出屏幕左侧,实现起来也很简单,与上面的 Slide Down 动画步骤类似,不同之处在于以下几个地方:

  • 创建一个 SlideRightTransitionAnimator

  • animateTransition 方法中这样实现:

    let offScreenLeft = CGAffineTransformMakeTranslation(- container.frame.width, 0)
    let offScreenRight = CGAffineTransformMakeTranslation(container.frame.width, 0)
    
    
    // 设置 toView 的初始位置
    if isPresenting {
        toView.transform = offScreenLeft
    }
    
    
    // toView 和 fromView 会同时在屏幕区域内,因此在 present 和 dismiss 时,
    // toView 和 fromView 所代表的 mainView 和 detailView 刚好相反,
    // 所以在容器 view 中的顺序也要调整
    if isPresenting { 
        container.addSubview(fromView) 
        container.addSubview(toView)
    } else { 
        container.addSubview(toView)
        container.addSubview(fromView)
    }
    
    
    // 实际动画执行    
    if self.isPresenting {
        // 将 detail view 恢复到屏幕
        toView.transform = CGAffineTransformIdentity
    } else {
        // 反转过程,将 detial view 从左侧移出屏幕,main view 因为在底下,所以不用操作
        fromView.transform = offScreenLeft
    }
    
  • 添加自定义的 Slide Right 动画

    var slideRightTransition = SlideRightTransitionAnimator()
    switch selectedIndexPaths[0].row {
        case 0: toViewController.transitioningDelegate = self.slideDownTransition
        case 1: toViewController.transitioningDelegate = self.slideRightTransition
        default: break 
    }
    

3. 创建一个 Pop 动画

大概过程是 detail view 从中心位置开始变大,同时 main view 开始缩小一些,直到 detail view 填充满整个屏幕,与 Slide Right 相同的是,fromView 和 toView 都会同时在屏幕上(只不过层次顺序不同),不同的是这次的 transform 是放大和缩小

  • 创建一个 PopTransitionAnimator

  • animateTransition 方法中这样实现:

    let minimize = CGAffineTransformMakeScale(0, 0)
    let offScreenDown = CGAffineTransformMakeTranslation(0, container.frame.height)
    // 向下移动 15,然后再缩小一点
    let shiftDown = CGAffineTransformMakeTranslation(0, 15)
    let scaleDown = CGAffineTransformScale(shiftDown, 0.95, 0.95)
    // 设置 toView 的初始位置
    toView.transform = minimize
    
    
    // 实际动画执行
    if self.isPresenting {
        // main view 开始缩小一点,并变得透明
        fromView.transform = scaleDown 
        fromView.alpha = 0.5
        // detail view 开始填充屏幕
        toView.transform = CGAffineTransformIdentity
    } else {
        // detail view 开始从底部离开屏幕
        fromView.transform = offScreenDown
        // main view 透明度变回来,并恢复原状
        toView.alpha = 1.0
        toView.transform = CGAffineTransformIdentity
    }
    
  • 添加自定义的 Pop 动画

    var popTransition = PopTransitionAnimator()
    switch selectedIndexPaths[0].row {
        case 0: toViewController.transitioningDelegate = self.slideDownTransition
        case 1: toViewController.transitioningDelegate = self.slideRightTransition
        case 2: toViewController.transitioningDelegate = self.popTransition
        default: break 
    }
    

4. 创建一个旋转动画

大概过程是,detail view 从上面绕原点旋转进入屏幕,而 main view 从相反的方向绕原点旋转出屏幕,最终稳定状态下,main view 和 detail view 并不会同时在屏幕上,因此不用考虑 container 中添加的先后顺序。具体的动画这次是一个旋转的 transform:

  • 创建一个 RotateTransitionAnimator

  • animateTransition 方法中:

    // 正值是顺时针,负值是逆时针 -π/2
    let rotateOut = CGAffineTransformMakeRotation(-90 * CGFloat(M_PI) / 180)
    
    
    // 设置 main view 和 detail view 旋转的锚点为原点
    toView.layer.anchorPoint = CGPoint(x:0, y:0) 
    fromView.layer.anchorPoint = CGPoint(x:0, y:0) 
    // 设置 main view 和 detail view 图层起始的位置(默认是在 view 的中心)
    toView.layer.position = CGPoint(x:0, y:0) 
    fromView.layer.position = CGPoint(x:0, y:0)
    
    
    // 设置 toView 的初始位置
    toView.transform = rotateOut
    
    
    // 实际动画执行
    if self.isPresenting {
        fromView.transform = rotateOut fromView.alpha = 0
        toView.transform = CGAffineTransformIdentity 
        toView.alpha = 1.0
    } else {
        fromView.alpha = 0
        fromView.transform = rotateOut
        toView.alpha = 1.0
        toView.transform = CGAffineTransformIdentity
    }
    

    注意:设置完锚点,还需要设置 view 图层的 position。如果锚点设置为(0,0)但不设置 view layer 的 position,那么 view layer position 的默认值是 view 的中心位置,也就是说会围绕 view 的中心点进行旋转

  • 添加自定义的 旋转 动画

    var rotateTransition = RotateTransitionAnimator()
    switch selectedIndexPaths[0].row {
        case 0: toViewController.transitioningDelegate = self.slideDownTransition
        case 1: toViewController.transitioningDelegate = self.slideRightTransition
        case 2: toViewController.transitioningDelegate = self.popTransition
        case 3: toViewController.transitioningDelegate = self.rotateTransition
        default: break 
    }
    


二十四、BUILDING A SLIDE DOWN MENU

类似于 Medium App,本章打造一个 Slide Down Menu。这种结构主要分为两部分,Menu VC 和 News VC,通常 News VC 叠加在 Menu VC 的上面,点击 Menu Button,News VC 向下滑动一段固定距离,露出 Menu VC

和上一章类似,Slide Down 动画的核心还是创建一个遵循 UIViewControllerAnimatedTransitioningUIViewControllerTransitioningDelegateanimator object

1. 创建 Slide Down Menu Animator

class MenuTransitionManager: NSObject, UIViewControllerAnimatedTransitioning, UIViewControllerTransitioningDelegate {  
    var duration = 0.5
    var isPresenting = false

    // 构造一个截图 view
    var snapshot:UIView? 

    func transitionDuration(transitionContext: UIViewControllerContextTransitioning) -> NSTimeInterval { 
        return duration
    }

    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
        // Get reference to our fromView, toView and the container view
        let fromView = transitionContext.viewForKey(UITransitionContextFromViewKey)! 
        let toView = transitionContext.viewForKey(UITransitionContextToViewKey)!

        // Set up the transform for sliding
        let container = transitionContext.containerView()
        let moveDown = CGAffineTransformMakeTranslation(0, container.frame.height - 150)
        let moveUp = CGAffineTransformMakeTranslation(0, -50)

        // Add both views to the container view 
        if isPresenting {
            toView.transform = moveUp
            // 截图,节省性能
            snapshot = fromView.snapshotViewAfterScreenUpdates(true)
            container.addSubview(toView)
            container.addSubview(snapshot!) 
        }

        // Perform the animation
        UIView.animateWithDuration(duration, delay: 0.0, 
                            usingSpringWithDamping: 0.9, 
                             initialSpringVelocity: 0.3, 
                                           options: nil, animations: {
            if self.isPresenting {
                self.snapshot?.transform = moveDown 
                toView.transform = CGAffineTransformIdentity
            } else {
                self.snapshot?.transform = CGAffineTransformIdentity 
                fromView.transform = moveUp
            }
        }, completion: { finished in
            transitionContext.completeTransition(true) 
            if !self.isPresenting {
                // 移除截图
                self.snapshot?.removeFromSuperview() 
            }
        }) 
    }

    func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        isPresenting = false
        return self 
    }

    func animationControllerForPresentedController(presented: UIViewController,
                             presentingController presenting: UIViewController,
                                     sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        isPresenting = true
        return self 
    }
}

从 iOS 7 开始,苹果允许使用 UIView-Snapshotting API 创建简易截图

2. 使用这个 Slide Down Menu Animator

一般两个 VC 进行过渡,通常在 Source VC(fromVc)中创建 Animator 对象,然后将这个 Animator 传递给 Destination VC(toVc)transitioningDelegate

因此在 News VC 中创建 var menuTransitionManager = MenuTransitionManager(),然后在 prepareForSegue 方法中传给 Menu VC 的 transitioningDelegate

3. 在 Presenting 状态下检测 News VC 界面的点按(Tap)手势

在 Presenting 状态下,News VC 表面其实是一个 Snapshot,所以其实是为 Snapshot 添加一个手势,这次我们用协议(Protocol)来实现。

  1. MenuTransitionManager 中定义一个协议

    @objc protocol MenuTransitionManagerDelegate { 
        func dismiss()
    }
    

    该协议必须暴露给 OC runtime,也就是将能够被 UITapGestureRecognizer 访问,所以必须加 @objc 关键字

  2. 修改 snapshot 的定义,为其添加 tap 手势

    var snapshot:UIView? { 
        didSet {
            if let _delegate = delegate {
                let tapGestureRecognizer = UITapGestureRecognizer(target:_delegate, action: "dismiss") 
                snapshot?.addGestureRecognizer(tapGestureRecognizer)
            } 
        }
    }
    
  3. 让 News VC 遵循自定义的 MenuTransitionManagerDelegate 协议

    Class NewsTableViewController: UITableViewController, MenuTransitionManagerDelegate
    
    
    //并实现协议中的 dismiss 方法
    func dismiss() {
    dismissViewControllerAnimated(true, completion: nil)
    }
    
    
    //在 prepareForSegue 方法中将自己(News VC)设置为 **menuTransitionManager** 的 `delegate`
    self.menuTransitionManager.delegate = self
    


二十五、SELF SIZING CELL AND DYNAMIC TYPE

1. 介绍 Sizeing Cell 和 Dynamic Type

从 iOS 8 开始,Apple 介绍了 UITableView 的新特性 Self Sizing Cells,可以根据内容动态调整 cell 高度,要使用它,你需要做到以下两步:

  • 为 prototype cell 设置 auto layout 约束
  • 设置 tableView 的 rowHeight 为 UITableViewAutomaticDimension

Dynamic Type 是 iOS 7 放出的一个新特性,它允许用户自己修改 App 中字体大小。

2. Sizeing Cell Demo

参照上面介绍的步骤,完成:

  • 为 prototype cell 中每个元素设置 auto layout 约束
  • tableView.rowHeight = UITableViewAutomaticDimension
  • 注意 label 元素,在 IB 中将行数(lines)设置为 0,这样就能根据内容动态调整了

这里有个 Bug:当你第一次切换到横屏时,一部分 cell size 并没有被正确设置,解决办法:

override func traitCollectionDidChange(previousTraitCollection: UITraitCollection?) {  
    self.tableView.reloadData() 
}

关于 Sizeing Cell 还是会有些 bug,还有一种解决办法见 这里

3. Dynamic Type Demo

  1. 为了将 cell 上的 label 改为 Dynamic Type,最简单的办法就是,选中这个 label,然后在 IB 中将 Font 改为 preferred font,也就是 headlinebody 等。

    然后去 Settings > General > Accessibility > Larger Text 启用 Larger Accessibility Sizes 选项,滑动 slider 改变 font size。最后运行 app,查看字体大小是否改变。

  2. 动态改变字体大小,虽然上一步将 cell 上的 label 改为了 Dynamic Type,但是还不能动态改变,也就是每次滑动 slider 修改字体大小,要重新运行 app 才能看到改变后的效果。

    为了让字体大小实时改变,我们需要监听 UIContentSizeCategoryDidChangeNotification 这个通知,这个通知会在设置里调整字体大小时发布。

    NSNotificationCenter.defaultCenter().addObserver(self, 
                                    selector: "onTextSizeChange:",
                                    name: UIContentSizeCategoryDidChangeNotification,
                                    object: nil)
    

    // 收到通知后执行

    func onTextSizeChange(notification: NSNotification) { 
        self.tableView.reloadData()
    }
    

    对于自定义的 cell,我们每次刷新时都需要重新设置每一个 cell 中的字体,在 tableView(_:cellForRowAtIndexPath:) 方法中添加下面的 Code

    override func tableView(tableView: UITableView, cellForRowAtIndexPath 
        indexPath: NSIndexPath) -> UITableViewCell {
        .....    
        // Set the font style
        cell.nameLabel.font = UIFont.preferredFontForTextStyle(UIFontTextStyleHeadline)
        cell.addressLabel.font = UIFont.preferredFontForTextStyle(UIFontTextStyleSubheadline)
        cell.descriptionLabel.font = UIFont.preferredFontForTextStyle(UIFontTextStyleBody)
        return cell    
    }    
    

    preferredFontForTextStyle 方法是基于当前内容和指定的 text style 返回合适的系统字体,下面是可用的 text style 选项:

    • UIFontTextStyleHeadline
    • UIFontTextStyleSubheadline
    • UIFontTextStyleBody
    • UIFontTextStyleFootnote
    • UIFontTextStyleCaption1
    • UIFontTextStyleCaption2

    最后一步,移除通知的观察者

    deinit { 
        NSNotificationCenter.defaultCenter().removeObserver(self)
    }
    

4. 使用自定义字体

现在我们将 cell 上的 label 字体改为了 Dynamic Type,也能随着设置中字体的大小而动态变化,但是还有一个问题需要解决:cell 的默认字体是 Helvetica Neue,本节就来修改这个字体。主要步骤如下:

  • 使用 UIFontDescriptor 并提供一个 text style 从而得到 font descriptor 对象
  • 根据 font descriptor 得到 font size
  • 创建一个 UIFont 实例,需要两个参数:name,以及上一步我们得到的 font size
  • 将这个 UIFont 实例赋给 cell 中的 label.font

这里使用了 extensionsUIFont 添加了一个 preferredCustomFontForTextStyle: 方法来实现添加自定义字体的目的:

extension UIFont {  
    class func preferredCustomFontForTextStyle (textStyle: NSString) -> UIFont {
        let font = UIFontDescriptor.preferredFontDescriptorWithTextStyle(textStyle) 
        let fontSize: CGFloat = font.pointSize

        if textStyle == UIFontTextStyleHeadline {
            return UIFont(name: "AvenirNext-Medium", size: fontSize)!
        } else if (textStyle == UIFontTextStyleSubheadline) { 
            return UIFont(name: "Avenir-Medium", size: fontSize)!
        } else {
            return UIFont(name: "Avenir-Light", size: fontSize)!
        } 
    }
}

传递一个 textStyle 返回一个 UIFont

接下来修改之前在 tableView(_:cellForRowAtIndexPath:) 中的 set font 方法,使用我们为 UIFont 新扩展的这个方法

// Set the font style
cell.nameLabel.font = UIFont.preferredCustomFontForTextStyle(UIFontTextStyleHeadline)  
cell.addressLabel.font = UIFont.preferredCustomFontForTextStyle(UIFontTextStyleSubheadline)  
cell.descriptionLabel.font = UIFont.preferredCustomFontForTextStyle(UIFontTextStyleBody)  


二十六、XML Parsing and RSS

1. 使用 NSXMLParser 解析 XML

iOS 用 NSXMLParser 类 进行 XML 解析,并提供 delegate 方法便于我们处理解析过程中的每一个步骤。在 iOS 中所有的 XML 数据 都可以看做是一个 XML document,下面是 delegate 中处理数据的一些核心方法:

  • parserDidStartDocument 解析真正开始时会被调用
  • parserDidEndDocument 解析完成
  • parser(_:parseErrorOccurred:) 解析出错
  • parser(_:didStartElement:namespaceURI:qualifiedName:attrib utes:) 解析到 tag 元素 (opening tag) 时被调用
  • parser(_:didEndElement:namespaceURI:qualifiedName:) 解析到 tag 结尾元素 (close tag) 时被调用
  • parser(_:foundCharacters:) 解析元素的内容,第二个参数包含了解析到的元素的内容

2. 一个简单的 RSS 阅读器介绍

需要解析的 XML 格式,简化一下大概是下面这个样子:

<item>  
<title>Apple Reports Record First Quarter Results</title>  
<description>Apple has announced financial results...</description>  
<pubDate>Tue, 27 Jan 2015 14:22:51 PST</pubDate>  
</item>  

我们主要感兴趣的是 item tag 中包含的内容:

  • title
  • description
  • pubDate

我们的任务就是解析 XML 数据然后得到所有的 items,并显示到 table view 上

当我们谈论 XML 解析时,其实是有两种通用的解析方式:

  • Tree-based 将 XML 结构看成是树,然后把树的各个部分看成一个对象来处理,也就是 DOM 的方式
  • Event-driven 解析基于事件,SAX 解析就是这种方式的代表

而我们下面介绍的 NSXMLParser 就是基于事件的解析,开始解析时 NSXMLParser 会通知他的 delegate 下面一些事件:

通过实现这些 delegate 方法,你可以从中获取到你感兴趣的数据然后相应的保存他们

3. 开始解析

我们单独创建一个类 FeedParser 来处理解析 XML 的具体事宜,下面是在这个类中要实现的主要步骤

  • 异步下载 RSS Feed 对应的内容,NSXMLParser 虽然提供了下载方法,但他只工作在同步模式下,这样下载过程会阻碍 UI,为了达到异步下载,我们将使用 NSURLSession 来异步下载。
  • XML 的内容下载完毕后,初始化一个 NSXMLParser 对象开始解析。
  • 使用 NSXMLParser delegate 方法处理解析到的数据,在解析过程中,我们将会遍历 tag 信息,并将获取到的数据以元组的形式保存到数组中
  • 最后调用 parserCompletionHandler

  • 遵守 NSXMLParserDelegate

    class FeedParser: NSObject, NSXMLParserDelegate
    
    
    创建元组数组用来存储解析到的 items
    private var rssItems:[(title: String, description: String, pubDate: String)] = []
    
    
    声明临时变量用来存储当前解析到的元素 `tag` 和元素中的内容,并且赋值的时候去掉**空格**和**换行符**
    private var currentElement = "" private 
    var currentTitle:String = "" {
        didSet { 
            currentTitle = currentTitle.stringByTrimmingCharactersInSet(NSCharacterSet.whitespace AndNewlineCharacterSet())
        } 
    }
    
    
    private var currentDescription:String = "" { 
        didSet {
            currentDescription = currentDescription.stringByTrimmingCharactersInSet(NSCharacterSet.whit espaceAndNewlineCharacterSet())
        } 
    }
    
    
    private var currentPubDate:String = "" { 
        didSet {
            currentPubDate = currentPubDate.stringByTrimmingCharactersInSet(NSCharacterSet.whites paceAndNewlineCharacterSet())
        } 
    

    }

    声明一个闭包,在整个解析完成后会使用它
    private var parserCompletionHandler:([(title: String, description: String, pubDate: String)] -> Void)?
    
  • 创建解析函数,该函数带两个参数,一个是 XML 的 URl,另外一个就是上面定义的 completionHandler

    func parseFeed(feedUrl: String, completionHandler: ([(title: String, description: String, pubDate: String)] -> Void)?) -> Void {
        // completionHandler 闭包会由调用者实现并传递进来(并未执行),解析完毕才会执行
        self.parserCompletionHandler = completionHandler
        let request = NSURLRequest(URL: NSURL(string: feedUrl)!)    
        let urlSession = NSURLSession.sharedSession()
        let task = urlSession.dataTaskWithRequest(request, 
            completionHandler: { (data, response, error) -> Void in        
            if error != nil { 
                println(error.localizedDescription)
            }        
            // Parse XML data 下载完成后启动解析
            let parser = NSXMLParser(data: data) 
            parser.delegate = self
            parser.parse()
        })    
        task.resume()
    }
    
  • 实现 NSXMLParserDelegate

    当新元素被发现时,首先调用的是 didStartElement,我们这里要找的是 tag <item>

    func parser(parser: NSXMLParser!, didStartElement elementName: String!, 
        namespaceURI: String!, qualifiedName qName: String!, 
        attributes attributeDict: [NSObject : AnyObject]!) {    
        currentElement = elementName
        if currentElement == "item" { 
            currentTitle = "" 
            currentDescription = "" 
            currentPubDate = ""
        } 
    }
    
    
    当 `<item>` 元素被发现后,我们继续解析他的一些属性
    
    
    func parser(parser: NSXMLParser!, foundCharacters string: String!) {
        switch currentElement {
            case "title": currentTitle += string
            case "description": currentDescription += string 
            case "pubDate": currentPubDate += string 
            default: break
        } 
    }
    

    解析到 string 可能只包含一部分,所以先添加到末尾

    当解析到 元素末尾是会调用 parser(_:didEndElement:namespaceURI:qualifiedName:) 这里将解析到的数据对象(元组)添加到事先准备的数组中

    func parser(parser: NSXMLParser!, didEndElement elementName: String!, 
    namespaceURI: String!, qualifiedName qName: String!) {
        if elementName == "item" {
            var rssItem = (title: currentTitle, description: currentDescription, pubDate: currentPubDate) 
            rssItems += [rssItem]
        } 
    }
    

    整个文档解析完了会调用 parserDidEndDocument,在这个方法中我们将最终的解析结果(包含元组对象的数组)作为参数传递给 parseCompletionHandler

    func parserDidEndDocument(parser: NSXMLParser!) {
    // 执行闭包(当调用 parseFeed 方法时作为参数传入的)
        self.parserCompletionHandler?(rssItems) 
    }
    

    闭包一般是先定义了,真正要到合适的时机才会执行(因为是方法)

    打印一些解析中的错误

    func parser(parser: NSXMLParser!, parseErrorOccurred parseError: NSError!) { 
        println(parseError.localizedDescription)
    }
    
  • 将解析结果显示到 TableView 上

    • 首先创建一个 FeedParser 类的实例,声明一个包含元组的数组对象用来存储解析后的数据
    • 实现 completionHandler 这个闭包,调用 FeedParser 实例的 parseFeed 解析方法,然后将前面实现的 completionHandler 闭包作为参数传递给 parseFeed 方法。
  • let feedParser = FeedParser()
    feedParser.parseFeed("https://www.apple.com/main/rss/hotnews/hotnews.rss", completionHandler: { 
        (rssItems: [(title: String, description: String, pubDate: String)]) -> Void in
        self.rssItems = rssItems
        // 因为是在后台下载的,所以解析也是在后台,然后更新UI要回到前台
        dispatch_async(dispatch_get_main_queue(), {
            self.tableView.reloadData()
        })
    })
    

    最后用 self.rssItems 作为 dataSource 更新 tableView

二十七、APPLYING A BLURRED BACKGROUND USING UIVISUALEFFECT

iOS 8 Apple 终于提供了 API 来自定义图片创建半透明(translucent)和模糊(blurring-style)效果。

1. 理解 UIVibrantEffect 和 UIVisualEffectView

  • UIVibrantEffect 有两种视觉效果,主要是 blur 和 vibrant
    • UIBlurEffect 主要用来实现模糊效果,具体效果有三种:ExtraLightLightDark
    • UIVibrancyEffect 主要用来调整显然内容的颜色,使其看起来更加清晰
  • UIVisualEffectView 应用上面 visual effect 效果的真正的 view

假如我们有一个 UIImageView 对象作为背景,下面展示如何模糊这个背景:

// 创建一个模糊效果
let blurEffect = UIBlurEffect(style: UIBlurEffectStyle.Light)  
// 用这个模糊效果创建一个 UIVisualEffectView 对象
let blurEffectView = UIVisualEffectView(effect: blurEffect)  
// 添加到背景上
backgroundImageView.addSubview(blurEffectView)  

2. Demo

有五张照片

private var imageSet = ["cloud", "coffee", "food", "pmq", "temple"]  

随机显示并模糊背景

override func viewDidLoad() {  
    super.viewDidLoad()

    // Randomly pick an image
    let selectedImageIndex = Int(arc4random_uniform(5))

    // Apply blurring effect
    backgroundImageView.image = UIImage(named: imageSet[selectedImageIndex]) 
    let blurEffect = UIBlurEffect(style: UIBlurEffectStyle.Light)
    let blurEffectView = UIVisualEffectView(effect: blurEffect)
    blurEffectView.frame = view.bounds 
    backgroundImageView.addSubview(blurEffectView)
}


二十八、USING TOUCH ID FOR AUTHENTICATION

1. 介绍 Touch ID 和 Local Authentication

Touch ID 是苹果在 5S 上最早推出的,在 iOS 7 上刚推出时,你只能使用他来解锁 iPhone 和验证 App Store 或 iTunes Store。指纹传感器上的安全和隐私是公众关心的话题,对应的是设备 不会存储任何关于指纹信息,扫描到的指纹会被转换为数学格式,并且加密存储在芯片的安全区域,指纹数据被限制只能用来做验证,就连 iOS 自己也没有办法访问指纹信息。

从 iOS 8 开始,苹果向开发者开放了 Touch ID 验证的 API,整个 Touch ID 是基于一个叫做 Local Authentication 的新框架,你可以在你的 APP 中使用 touch id 验证登录或访问一些敏感信息。整个 Local Authentication 的核心是 LAContext 类,包含下面两个方法:

  • canEvaluatePolicy(_:error:) 给定一个验证策略,判断指纹识别是否可用
  • evaluatePolicy(_:localizedReason:reply:) 执行这个方法时会弹出一个对话框请求指纹扫描,整个验证过程是异步的,当完成后会调用一个闭包对结果进行处理。

2. Touch ID App Demo

这个 Demo 使用 Touch ID 来代替密码,主要思路是,Demo 一运行,弹出个登录对话框,提供指纹扫描和密码两种方式登录,一旦验证成功,则执行一个 segue 切换到 Home screen。指纹验证一旦失败或设备不支持则自动切换到密码登录的界面。

  • 第一次运行,App 弹出 Touch ID 对话框,请求指纹扫描
  • 无论什么原因,指纹验证失败了或用户点击了输入密码,则切换到密码登录界面
  • 验证成功,显示主界面

3. 使用 Local Authentication 框架

开始前,我们需要手动添加这个框架,点击 Build Phases tab,在 Link Binary with Libraries 下添加 Local Authentication framework 到工程中来。接着在实际工程中导入 import LocalAuthentication

实现指纹识别,第一步先把登录框隐藏掉

loginView.hidden = true  

创建 authenticateWithTouchID 方法 开始验证

func authenticateWithTouchID() {  
    // Get the local authentication context.
    let localAuthContext = LAContext()
    let reasonText = "Authentication is required to sign in AppCoda" 
    var authError: NSError?
    // 首先判断验证能否进行指纹验证
    if !localAuthContext.canEvaluatePolicy(LAPolicy.DeviceOwnerAuthenticationWithBiometrics, error: &authError) { 

        println(authError?.localizedDescription)

        // Display the login dialog when Touch ID is not available (e.g. in simulator)
        showLoginDialog()

        return 
    }

    // Perform the Touch ID authentication 弹出对话框,请求指纹扫描,理由通过 reason text 传递给用户,扫描完指纹后就开始执行验证,验证结果将会在回调中返回
    localAuthContext.evaluatePolicy(LAPolicy.DeviceOwnerAuthenticationWithBiometrics, localizedReason: reasonText, reply: { 
        (success: Bool, error: NSError!) -> Void in
        // 验证成功 回主线程执行 segue
        if success {
            println("Successfully authenticated")
            NSOperationQueue.mainQueue().addOperationWithBlock({
                self.performSegueWithIdentifier("showHomeScreen", sender: nil) })

        } else {
            switch error.code {
            case LAError.AuthenticationFailed.rawValue:
                println("Authentication failed")
            case LAError.PasscodeNotSet.rawValue:
                println("Passcode not set")
            case LAError.SystemCancel.rawValue:
                println("Authentication was canceled by system") 
            case LAError.UserCancel.rawValue:
                println("Authentication was canceled by the user") 
            case LAError.TouchIDNotEnrolled.rawValue:
                println("Authentication could not start because Touch ID has no enrolled fingers.") 
            case LAError.TouchIDNotAvailable.rawValue:
                println("Authentication could not start because Touch ID is not available.")
            case LAError.UserFallback.rawValue:
                println("User tapped the fallback button (Enter Password).")
            default: println(error.localizedDescription)
            }
            // Fallback to password authentication 失败了改用 密码验证
            NSOperationQueue.mainQueue().addOperationWithBlock({
                self.showLoginDialog() 
            })
        } 
    })
}

目前只有 DeviceOwnerAuthenticationWithBiometrics 这一个策略选项可用

下面是一些指纹验证失败的 Error codes

  • AuthenticationFailed 验证错误,指纹不匹配
  • UserCancel 用户取消
  • UserFallback 用户选择密码验证而不是指纹验证
  • SystemCancel 指纹验证被系统取消了,比如另外一个应用程序切换到前台
  • PasscodeNotSet 密码未设置
  • TouchIDNotAvailable 设备不支持指纹
  • TouchIDNotEnrolled 用户还没有设置 touch id

最后一步在 viewDidLoad 中执行验证,确保一打开 App 就让用户选择验证 authenticateWithTouchID()

4. 密码验证

这一步没什么说的,只要是指纹识别失败或不支持的都转换到密码识别上来,我们这里实现一个登录失败后按钮的小动画

@IBAction func authenticateWithPassword() {
    if emailTextField.text == "hi@appcoda.com" && passwordTextField.text == "1234" {
        performSegueWithIdentifier("showHomeScreen", sender: nil) 
    } else {
        // Shake to indicate wrong login ID/password 
        loginView.transform = CGAffineTransformMakeTranslation(25, 0)
        UIView.animateWithDuration(0.2, delay: 0.0, usingSpringWithDamping: 0.15,
            initialSpringVelocity: 0.3, options: .CurveEaseInOut, animations: {
                self.loginView.transform = CGAffineTransformIdentity
        }, completion: nil) 
    }
}

-EOF-

如果感觉此文对你有帮助,请随意打赏支持作者 😘

chengway

认清生活真相之后依然热爱它!

Subscribe to Talk is cheap, Show me the world!

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!