模拟音乐 app 的 Now Playing 动画

原文:Recreating the Apple Music Now Playing Transition
作者:Warren Burton
译者:kmyhy

在许多 iPhone app 中的一种常见的可视化模板就是让一叠卡片从屏幕外边滑入。你可能在“提醒”之类的 app 中看过这个,它的列表是以一叠卡片的形式从下到上出现的。“音乐”app 也是这样的,当前曲目从最小化的播放器放大为一个全屏的卡片。

这些动画看起来并不复杂,它是以层叠的方式出现的。但如果你仔细看,实际上这个动画中包含了许多东西。和电影中的好的特效一样,好的动画总是以不引人注意的方式出现。

在本教程中,你将复制“音乐”app 的从小到大的卡片式动画。为了使问题简单化,你将使用常规的 UIKit API。

在这篇教程中,你需要具备:

  • Xcode 9.2 及以上版本
  • 熟悉自动布局的概念
  • 能够在 IB 中创建、修改 UI 及自动布局约束
  • 能够将代码中 IBOutlet 连接到 IB 对象
  • 熟悉 UIView 动画 API

开始

从这里下载开始项目

Build & run。app 名字叫做 RazePlayer,它具有一个简单的音乐类 app 的 UI。点击 collection view 中的一首歌曲,底部的播放器会载入这首歌曲。播放器并不会真的播放这首歌曲,而是由播放列表来决定是否播放。

故事板

开始项目中包含了所有的 view controller,它们处于“半完成”状态,你可以将主要精力放在动画的创建上。打开 Main.storyboard 。

为了一开始能够正常显示视图,请使用 iPhone 8 模拟器。

在故事板中,从左到右分别是:

  • Tab Bar Controller,其中有一个 SongViewController:当 app 一启动时看到的 collection view。上面是一些假的、重复的曲目集。
  • 迷你播放器 View Controller:以子控制器的形式嵌入到 SongViewController 中。这视图你需要进行动画。
  • Maxi Song Card View Controller:这个视图承载的是动画的最终状态。和这个故事板一起,它将作为你主要会用到的类。
  • Song Play Control View Controller:你会在动画的过程中使用到它。

在项目导航器中展开这个个项目。这个项目使用了标准的 MVC 模式,将数据和 View Controller 分离。你使用得最频繁的文件是 Song.swift,它代表了一首单独的歌曲。

如果你愿意,你可以在稍后浏览这些文件,但在本教程中,你不需要了解太多。在本教程中,你将在 View Layer 文件夹下的这几个文件中进行工作:

  • Main.storyboard:包含这个项目的所有 UI。
  • SongViewController.swift: 主视图控制器。
  • MiniPlayerViewController.swift: 显示当前选中的曲目。
  • MaxiSongCardViewController.swift: 以卡片动画的方式显示从播放器的最小化状态变到播放器的最大化状态。
  • SongPlayControlViewController.swift: 包含了这个动画的其它 UI。

稍微看一下苹果的“音乐”app 是如何从迷你播放器变成一张大卡片的。专辑封面不断地放大成一张大图,tab bar 向下移动并消失。很难在动画过程中捕捉到这个动画的所有特效。幸好,在你克隆这个动效的时候,可以将动画变成慢动作。

第一个任务是从迷你播放器变成全屏卡片。

对背景图片进行动画

iOS 动画经常会释放一些烟雾来愚弄用户的眼睛,让它们以为它们看到的一切都是真的。你的第一个任务就是让它显示缩小的背景内容。

创建一个假背景

打开 Main.storyboard 展开 Maxi Song Card View Controller。有两个视图,我们将用于作为背景图片和模糊图层。

打开 MaxiSongCardViewController.swift 在 dimmerLayer outlet 下面添加几个属性:

@IBOutlet weak var backingImageTopInset: NSLayoutConstraint!
@IBOutlet weak var backingImageLeadingInset: NSLayoutConstraint!
@IBOutlet weak var backingImageTrailingInset: NSLayoutConstraint!
@IBOutlet weak var backingImageBottomInset: NSLayoutConstraint!

然后,按住 option 键,在项目导航器中,点击 Main.storyboard,打开助手编辑器。然后 MaxiSongCardViewController.swift 会在左边打开,而 Main.storyboard 会在右边打开。如果你在南半球,你也可以用别的方法打开助手编辑器。

接着,将背景图片的 IBOutlet 连接到故事板上:

  • 展开 MaxiSongCardViewController 的顶级对象以及它的顶层约束。
  • 将 backingImageTopInset 连接到 Backing Image View 的 top 约束。
  • 将 backingImageBottomInset 连接到 Backing Image View 的 bottom 约束。
  • 将 backingImageLeadingInset 连接到 Backing Image View 的 leading 约束。
  • 将 backingImageTrailingInset 连接到 Backing Image View 的 trailing 约束。

然后准备呈现 MaxiSongCardViewController。按 Cmd+回车键,或者 View ▸ Standard Editor ▸ Show Standard Editor 来关闭助手编辑器。

打开 SongViewController.swift。首先,在文件底部添加一个扩展:

extension SongViewController: MiniPlayerDelegate {
  func expandSong(song: Song) {
    //1.
    guard let maxiCard = storyboard?.instantiateViewController(
              withIdentifier: "MaxiSongCardViewController") 
              as? MaxiSongCardViewController else {
      assertionFailure("No view controller ID MaxiSongCardViewController in storyboard")
      return
    }

    //2.
    maxiCard.backingImage = view.makeSnapshot()
    //3.
    maxiCard.currentSong = song
    //4.
    present(maxiCard, animated: false)
  }
}

当你点击 迷你播放器 时,它委托给 SongViewController 来进行进一步处理。迷你播放器 不知道也不关心接下来的事情。

让我们分步解释上面的代码:

  1. 从故事板中初始化一个 MaxiSongCardViewControlelr。在 guard 语句中用一个 assetionFailure 在设计时确认对象创建是否创建成功。
  2. 创建一张 SongViewController 的截图,并传递给新视图控制器。makeSnapshot 是开始项目中的一个现成的助手方法。
  3. 当前曲目对象被传递给 MaxiSongCardViewController 对象。
  4. 以 modal 形式呈现,以非动画形式。被呈现的控制器将采用自己的动画序列。

然后,找到 prepare(for:sender:) 函数,在 miniPlayer = destination 一句后添加:

miniPlayer?.delegate = self

Build & run ,从曲目集中选择一首歌,点击迷你播放器。你会看到一个黑色的屏幕。OK!

你会发现状态栏消失了。先来搞定这个。

修改状态栏的外观

弹出的 controller 拥有一个黑色背景,因此你的状态栏应该用清淡的颜色样式。打开 MaxiSongCardViewController.swift,添加代码:

override var preferredStatusBarStyle: UIStatusBarStyle {
  return .lightContent
}

Build & run,点击 迷你播放器 弹出 MaxiSongCardViewController。状态栏现在应该变成了黑底白字。

这一节的最后一个任务是创建一种效果,让控制器和背景区别开来。

缩小 view controller。

打开 MaxiSongCardViewController.swift 添加属性:

let primaryDuration = 4.0 //最终会改成 0.5 
let backingImageEdgeInset: CGFloat = 15.0

一个是动画的时长,一个是背景图的留边。后面我们会让动画变快,现在故意弄慢一点,以便能够看清整个动作。

然后,在文件末尾添加一个扩展:

//背景图片的动画 
extension MaxiSongCardViewController { 

  //1.
  private func configureBackingImageInPosition(presenting: Bool) {
    let edgeInset: CGFloat = presenting ? backingImageEdgeInset : 0
    let dimmerAlpha: CGFloat = presenting ? 0.3 : 0
    let cornerRadius: CGFloat = presenting ? cardCornerRadius : 0

    backingImageLeadingInset.constant = edgeInset
    backingImageTrailingInset.constant = edgeInset
    let aspectRatio = backingImageView.frame.height / backingImageView.frame.width
    backingImageTopInset.constant = edgeInset * aspectRatio
    backingImageBottomInset.constant = edgeInset * aspectRatio
    //2.
    dimmerLayer.alpha = dimmerAlpha
    //3.
    backingImageView.layer.cornerRadius = cornerRadius
  }

  //4.
  private func animateBackingImage(presenting: Bool) {
    UIView.animate(withDuration: primaryDuration) {
      self.configureBackingImageInPosition(presenting: presenting)
      self.view.layoutIfNeeded() //这句很重要!
    }
  }

  //5.
  func animateBackingImageIn() {
    animateBackingImage(presenting: true)
  }

  func animateBackingImageOut() {
    animateBackingImage(presenting: false)
  }
}

分别做如下说明:

  1. 设置 image 的最后的 frame。我们通过图片的纵横比来设置垂直的留边,这样图片比例不会失真。
  2. 模糊遮罩层是一个 UIView,位于 Image View 之上,黑色的背景色。设置 alpha 值以便让图片稍微变得模糊一点。
  3. 设置图片的圆角。
  4. 使用最简单的 UIView 动画 API,告诉 image view 以动画方式改变成新的布局。在对自动布局约束进行动画时,我们必须在动画块中调用 layoutIfNeeded() 方法,否则动画不会被执行。
  5. 通过公开的 getter 方法,让我们的代码保持简洁。

然后,在 viewDidLoad() 方法中,在 super 一句后添加:

backingImageView.image = backingImage

这里将先前从 SongViewController 截取的截图设置为背景图。

最后在 viewDidAppear(_:) 方法最后添加:

animateBackingImageIn()

当视图显示时,执行动画。

Build & run,选择一首歌,点击迷你播放器。你会看到当前 view controller 非常缓慢地后退到背景中……

干得漂亮!完成了动画中的第一部分。接下来一个相当重要的内容,将迷你播放器中的缩略图放大成卡片中的大图。

放大曲目图片

打开 Main.storyboard 展开视图树。

你需要关注的是这些 view:

  • Cover Image Container:一个白色背景的 UIView。你将在 scroll view 中改变它的位置。
  • Cover Art Image:你将对这个 UIImageView 进行变形。它的背景是黄色,这样它就很容易在 Xcode 中识别出来。这个视图有两个地方值得注意:

    • Aspect:设置为 1:1。这表示它总是一个正方形。
    • Height:一个固定的值。待会你会知道为什么。

打开 MaxiSongCardViewController.swift。你可以看到这两个 view 和关闭按钮,都已经创建和连接了对应的 outlet:

//cover image
@IBOutlet weak var coverImageContainer: UIView!
@IBOutlet weak var coverArtImage: UIImageView!
@IBOutlet weak var dismissChevron: UIButton!

然后,找到 viewDidLoad(),删除下面几句:

//DELETE THIS LATER
scrollView.isHidden = true

这会让 UIScrollView 显示。它之前一直隐藏,以便为了让你看清背景图片上发生了什么。

然后,在 viewDidLoad() 末尾添加下面几行:

coverImageContainer.layer.cornerRadius = cardCornerRadius
coverImageContainer.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner]

这里只设置了上面两个角的圆角半径。

Build & run,点击迷你播放器,你会看到在背景的截图上显示了 container viwe 和 image view。

注意看 image view 的圆角。它没有使用一句代码,完全是通过用户定义的运行时属性面板来实现的。

设置封面图片的约束

在这一部分,你将添加封面图片动画中会用到的一些约束。

打开 MaxiSongCardViewController.swift。接着,添加下列约束:

@IBOutlet weak var coverImageLeading: NSLayoutConstraint!
@IBOutlet weak var coverImageTop: NSLayoutConstraint!
@IBOutlet weak var coverImageBottom: NSLayoutConstraint!
@IBOutlet weak var coverImageHeight: NSLayoutConstraint!

然后,用助手编辑器打开 Main.storyboard,连接 outlet:

  • 连接 coverImageLeading、coverImageTop 和 coverImageBottom 到 image view 的leading、top 和 bottom 约束。
  • 连接 coverIamgeHeight 到 image view 的 height 约束。

最后一个约束是从 cover image container 顶部到 scroll view 的 content View 之间的约束。

打开 MaxiSongCardViewController.swift。然后,添加下列属性:

//cover image constraints
@IBOutlet weak var coverImageContainerTopInset: NSLayoutConstraint!

最后,连接 coverImageContainerTopInset 到 cover image container 的上边距约束上;这个约束在 IB 上的 constant 值为 57。

现在所有的约束都已经创建完毕,可以执行动画了。

Build & run;点击一首曲目,然后点击迷你播放器,确保一切正常。

创建数据源协议

你必须知道 cover image 动画时候的起点位置。你可以传递一个迷你播放器的引用给大播放器,以便将所需信息传递给它,但这会在 MiniPlayerViewController 和 MaxiSongCardViewController 之间创建一个强依赖关系。除此之外,我们可以用协议来传递这个信息。

关闭助手编辑器,添加下列协议到 MaxiSongCardViewController.swift 中:

protocol MaxiPlayerSourceProtocol: class {
  var originatingFrameInWindow: CGRect { get }
  var originatingCoverImageView: UIImageView { get }
}

然后,打开 MiniPlayerViewController.swift,在文件末添加下列代码:

extension MiniPlayerViewController: MaxiPlayerSourceProtocol {
  var originatingFrameInWindow: CGRect {
    let windowRect = view.convert(view.frame, to: nil)
    return windowRect
  }

  var originatingCoverImageView: UIImageView {
    return thumbImage
  }
}

这里定义了一个协议,用于告诉大播放器需要动画的信息。然后让 MiniPlayerViewController 实现这个协议,以便提供相应的信息。UIView 内置了一些转换矩形和点的方法,将会非常有用。

然后,打开 MaxiSongCardViewController.swift 在主类中添加下列属性:

weak var sourceView: MaxiPlayerSourceProtocol!

这个属性使用了弱引用以避免持有循环。

打开 SongViewController.swift,在 expandSong 方法的 present(_,animated:) 一句前添加:

maxiCard.sourceView = miniPlayer

这里将起始 view 的引用在初始化时传给大播放器。

开始动画

在这一节,你将所有艰苦工作的成功组装起来,将 image view 动画到指定位置。

打开 MaxiSongCardViewController.swift,添加如下扩展:

//Image Container animation.
extension MaxiSongCardViewController {

  private var startColor: UIColor {
    return UIColor.white.withAlphaComponent(0.3)
  }

  private var endColor: UIColor {
    return .white
  }

  //1.
  private var imageLayerInsetForOutPosition: CGFloat {
    let imageFrame = view.convert(sourceView.originatingFrameInWindow, to: view)
    let inset = imageFrame.minY - backingImageEdgeInset
    return inset
  }

  //2.
  func configureImageLayerInStartPosition() {
    coverImageContainer.backgroundColor = startColor
    let startInset = imageLayerInsetForOutPosition
    dismissChevron.alpha = 0
    coverImageContainer.layer.cornerRadius = 0
    coverImageContainerTopInset.constant = startInset
    view.layoutIfNeeded()
  }

  //3.
  func animateImageLayerIn() {
    //4.
    UIView.animate(withDuration: primaryDuration / 4.0) {
      self.coverImageContainer.backgroundColor = self.endColor
    }

    //5.
    UIView.animate(withDuration: primaryDuration, delay: 0, options: [.curveEaseIn], animations: {
      self.coverImageContainerTopInset.constant = 0
      self.dismissChevron.alpha = 1
      self.coverImageContainer.layer.cornerRadius = self.cardCornerRadius
      self.view.layoutIfNeeded()
    })
  }

  //6.
  func animateImageLayerOut(completion: @escaping ((Bool) -> Void)) {
    let endInset = imageLayerInsetForOutPosition

    UIView.animate(withDuration: primaryDuration / 4.0,
                   delay: primaryDuration,
                   options: [.curveEaseOut], animations: {
      self.coverImageContainer.backgroundColor = self.startColor
    }, completion: { finished in
      completion(finished) //fire complete here , because this is the end of the animation
    })

    UIView.animate(withDuration: primaryDuration, delay: 0, options: [.curveEaseOut], animations: {
      self.coverImageContainerTopInset.constant = endInset
      self.dismissChevron.alpha = 0
      self.coverImageContainer.layer.cornerRadius = 0
      self.view.layoutIfNeeded()
    })
  }
}

上述代码分为以下几步:

  1. 计算起始位置,用源视图的坐标减去 scroll view 的垂直偏移。
  2. 将 container view 放到开始位置。
  3. 将 container view 动画到结束位置。
  4. 首先让背景色以渐变形式变化,以免转换过于突兀。
  5. 其次以动画方式修改 container 的 top inset 并渐入关闭按钮。
  6. 将 container 动画到开始位置。这个方法稍后会用到。它是 animateImageLayerIn 的逆过程。

然后,在 viewDidAppear(_:) 方法最后添加:

animateImageLayerIn()

这让动画开始走时间线。

然后,在 viewWillAppear(_:) 方法添加:

configureImageLayerInStartPosition()

这样,在视图开始显示之前到达开始位置。这里使用了 viewWillAppear 方法,这样将 image layer 移动到开始位置的过程不会被用户觉察到。

Build & run,然后点击迷你播放器以弹出大播放器。你会看到 container 会上行到指定位置。它的形状没有发生改变,因为 container 的高度取决于 image view 的高度。

Source Image 的动画

打开 MaxiSongCardViewController.swift 添加一个扩展:

//cover image animation
extension MaxiSongCardViewController {
  //1.
  func configureCoverImageInStartPosition() {
    let originatingImageFrame = sourceView.originatingCoverImageView.frame
    coverImageHeight.constant = originatingImageFrame.height
    coverImageLeading.constant = originatingImageFrame.minX
    coverImageTop.constant = originatingImageFrame.minY
    coverImageBottom.constant = originatingImageFrame.minY
  }

  //2.
  func animateCoverImageIn() {
    let coverImageEdgeContraint: CGFloat = 30
    let endHeight = coverImageContainer.bounds.width - coverImageEdgeContraint * 2
    UIView.animate(withDuration: primaryDuration, delay: 0, options: [.curveEaseIn], animations:  {
      self.coverImageHeight.constant = endHeight
      self.coverImageLeading.constant = coverImageEdgeContraint
      self.coverImageTop.constant = coverImageEdgeContraint
      self.coverImageBottom.constant = coverImageEdgeContraint
      self.view.layoutIfNeeded()
    })
  }

  //3.
  func animateCoverImageOut() {
    UIView.animate(withDuration: primaryDuration,
                   delay: 0,
                   options: [.curveEaseOut], animations:  {
      self.configureCoverImageInStartPosition()
      self.view.layoutIfNeeded()
    })
  }
}

这段代码和 iamge container 的动画类似。让我们来过一遍:

  1. 将 cover image 通过 source view 中指定的信息放置到开始位置。
  2. 将 cover image 动画到终止位置。最后的高度等于 container 的宽度减去它的 insets。因为宽高比是 1:1,因此宽度等于高度。
  3. 对于关闭动画,将 cover image 动画到开始位置。

然后,在 viewDidAppear 方法中最后添加:

animateCoverImageIn()

这会在视图显示到屏幕上之后触发动画。

然后,在 viewWillAppear 方法最后添加:

coverArtImage.image = sourceView.originatingCoverImageView.image
configureCoverImageInStartPosition()

这里从数据源对象获取了 UIImage,传递给 image view。在特定情况下这样做是可以的,比如现在,因为 UIImage 中的像素足够多,图片不会被颗粒化或者被拉伸。

Build & run,image view 从一开始的缩略图长变大,同时 container view 的 frame 随之变大。

关闭动画

卡片顶部的按钮被链接到了 dismissAction(_:) 方法。目前这个方法只会操作一个关闭动作,没有任何动画。

和弹出 view controller 中所做的一样,你需要让 MaxiSongCardViewController 处理它自己的关闭动画。

打开 MaxiSongCardViewController.swift 将 dismissAction(_:) 修改为:

@IBAction func dismissAction(_ sender: Any) {
  animateBackingImageOut()
  animateCoverImageOut()
  animateImageLayerOut() { _ in
    self.dismiss(animated: false)
  }
}

这播放了一个和之前的呈现动画相反的动画。当动画完成,我们解散 MaxiSongCardViewController。

Build & run,弹出大播放器,然后点关闭按钮。封面图片和 container view 又缩回到迷你播放器的样子。但是有一个显示上的问题,就是 Tab bar 会闪一下。我们后面会搞定它。

显示曲目信息

再观察一下音乐 app,你会发现打开的卡片中包含一个进度条和音量控制,还列出了歌曲名、艺术家、专辑和下一曲。这些并不是完全都放在了一个 view controller 中——而是封装成组件。

接下来的任务是在 scroll view 中嵌入一个 View controller。为了节省时间,已经为你准备好了一个:SongPlayControlViewController。

嵌入子控制器

第一个任务是从 scroll view 中将底下的 image container 分离出来。

打开 Main.storyboard。删除 cover image container 底部到 superView 底部的约束。会提示有布局错误,说 scroll view 需要有一个 Y 坐标或者高度约束。不用管。

然后,你需要创建一个子视图控制器用于显示歌曲详情,步骤如下:

  1. 添加一个 Container View 作为 Scroll view 的子 view。
  2. 确保这个 Container Viw 位于视图树中的 Sketchy Skirt 的上层(也就是说它要在 Document Outline 中位于 Strechy Skirt 之下)。
  3. 这会多出一个 segue 以及一个 view controller 对象。删除这个自动添加的 view controller。

现在为新添加的 container view 添加下列约束:

  • Leading、Trailing 和 Bottom 约束。对齐到 scroll view,间距 0。
  • Top 对齐到 Cover Image Container 的底部,间距 30。

第一次放置视图时的 Y 坐标是很重要的,请将它放到 image container view 的下面,这样你定义约束时会更方便。

最后,将这个 Container View 所包含的 segue 绑定到 SongPlayControlViewController。按住 Control 键,从这个 container view 拖一条线到 SongPlayControlViewController。

松开鼠标,选择 Embed。

最后,需要将 scroll view 中的这个 Container View 的高度做个限制,以解决 scroll view content 缺乏高度约束的问题。

  1. 选中 container view。
  2. 打开 Add New Constraints 菜单。
  3. 设置 Height 为 400。在 height 约束前面打勾。
  4. 点击 Add 1 Constraint。

到此为止,所有的自动布局错误都将消失。

播放控件的动画

接下来的效果是在动画结束时,将播放控件从屏幕底部上移和 cover image 接在一起。

在标准编辑器中打开 MaxiSongCardViewController.swift,在助手编辑器中打开 Main.storyboard。

在 MaxiSongCardViewController 主类中添加属性:

//lower module constraints
@IBOutlet weak var lowerModuleTopConstraint: NSLayoutConstraint!

将这个 outlet 连接到 image container 和 Container View 之间的间距约束。

关闭助手编辑器,在 MaxiSongCardViewController.swift 中新增扩展:

//lower module animation
extension MaxiSongCardViewController {

  //1.
  private var lowerModuleInsetForOutPosition: CGFloat {
    let bounds = view.bounds
    let inset = bounds.height - bounds.width
    return inset
  }

  //2.
  func configureLowerModuleInStartPosition() {
    lowerModuleTopConstraint.constant = lowerModuleInsetForOutPosition
  }

  //3.
  func animateLowerModule(isPresenting: Bool) {
    let topInset = isPresenting ? 0 : lowerModuleInsetForOutPosition
    UIView.animate(withDuration: primaryDuration,
                   delay:0,
                   options: [.curveEaseIn],
                   animations: {
      self.lowerModuleTopConstraint.constant = topInset
      self.view.layoutIfNeeded()
    })
  }

  //4.
  func animateLowerModuleOut() {
    animateLowerModule(isPresenting: false)
  }

  //5.
  func animateLowerModuleIn() {
    animateLowerModule(isPresenting: true)
  }
}

这个扩展对 SongPlayControllerViewController 的 view 和 Image Container 之间的间距操作一个简单动画:

  1. 随便计算一个开始时的间距。 view 的高度减去宽度就行了。
  2. 将控制器放到开始位置。
  3. 根据不同方向,执行动画。
  4. 一个助手方法,将控制器动画到指定位置。
  5. 将控制器移出。

接下来将动画添加到时间线。首先,在 viewDidAppear 方法最后添加:

animateLowerModuleIn()

在 viewWillAppear 方法最后添加:

stretchySkirt.backgroundColor = .white // 避免封面图片和下面的 songPlayControl 之间显示出间隙
configureLowerModuleInStartPosition()

然后,在 dismissAction 方法中,调用 animateImageLayerOut(completion:) 一句前执行解散动画:

animateLowerModuleOut()

最后,在 MaxiSongCardViewController.swift 中添加这个方法将当前歌曲传递给新控制器。

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
  if let destination = segue.destination as? SongSubscriber {
    destination.currentSong = currentSong
  }
}

这里检查了 destination 是否实现是一个 SongSubscriber,然后将歌曲传递给它。这里演示了一个简单的依赖注入。

Build & run。弹出大播放器界面,你会看到 SongPlayControl 的视图会上移到指定位置。

隐藏 Tab Bar

在最终完成之前还有一个东西需要处理,这就是 tab bar。你可以修改 tab bar 的 frame,但这会将相关的 view controller 框架搞乱。所以,我们需要再释放一些烟雾弹:

  • 通过截屏获得 Tab Bar 的图片。
  • 将它传递给 MaxiSongCardViewController。
  • 对这张 tab bar 图片进行动画。

首先,在 MaxiSongCardViewController 中加入:

// 假 tabbar 的约束
var tabBarImage: UIImage?
@IBOutlet weak var bottomSectionHeight: NSLayoutConstraint!
@IBOutlet weak var bottomSectionLowerConstraint: NSLayoutConstraint!
@IBOutlet weak var bottomSectionImageView: UIImageView!

然后,打开 Main.storyboard 拖一个 Image View 到 MaxiSongCardViewController 中。你要将它放在视图树的 scroll view 的上层(在 document outline 中则是位于 scroll view 的下方)。

打开 Add Constraints 菜单,去掉 Constain to margins 选项。将它的 leading、trailing 和 bottom 对齐 superview,值都是 0。实际上,是对齐到了安全区。高度约束设置为 128,然后点击 Add 4 Constaints 创建约束。

接着,用助手编辑器打开 MaxiSongCardViewController.swift,将 3 个属性连接到 Image view。

  • bottomSectionImageView 连接到 Image View。
  • bottomSectionLowerConstraint 连接到 Bottom 约束。
  • bottomSectionHeight 连接到 height 约束。

最后,关闭助手编辑器,添加一个扩展到 MaxiSongCardViewController.swift:

// 假 tab bar 动画
extension MaxiSongCardViewController {
  //1.
  func configureBottomSection() {
    if let image = tabBarImage {
      bottomSectionHeight.constant = image.size.height
      bottomSectionImageView.image = image
    } else {
      bottomSectionHeight.constant = 0
    }
    view.layoutIfNeeded()
  }

  //2.
  func animateBottomSectionOut() {
    if let image = tabBarImage {
      UIView.animate(withDuration: primaryDuration / 2.0) {
        self.bottomSectionLowerConstraint.constant = -image.size.height
        self.view.layoutIfNeeded()
      }
    }
  }

  //3.
  func animateBottomSectionIn() {
    if tabBarImage != nil {
      UIView.animate(withDuration: primaryDuration / 2.0) {
        self.bottomSectionLowerConstraint.constant = 0
        self.view.layoutIfNeeded()
      }
    }
  }
}

这段代码和其它动画类似。每一步骤你都熟悉。

  1. 用指定图片赋给 image view,如果没有图片的话,将 height 设置为 0。
  2. 将 image view 移到屏幕底部以下。
  3. 将 image view 移到正常位置。

最后一件事情是在这个文件里执行动画。

首先,在 viewDidAppear 方法最后添加:

animateBottomSectionOut()

然后,在 viewWillAppear 方法最后添加:

configureBottomSection()

接着,在 dissmissAction 方法的 animateImageLayerOut(completion:) 一句之前添加:

animateBottomSectionIn()

然后,打开 SongViewController.swift 在 expandSong(song:) 方法的 present(animated:) 一句前添加:

if let tabBar = tabBarController?.tabBar {
  maxiCard.tabBarImage = tabBar.makeSnapshot()
}

在这里,我们队 Tab Bar 进行了截图,如果 Tab Bar 不为空,将截图传递给 MaxiSongCardViewController。

最后,打开 MaxiSongCardViewController.swift 将 primaryDuration 属性修改为 0.5,这样你就不必忍受慢吞吞的动画的折磨了!

Build & run,弹出大播放器界面, tab bar 会上移然后下降到正常的位置。

恭喜!你已经模仿了一个“音乐” app 的卡片动画(几乎是重新建造的)。

接下来做什么?

你可以从这里下载完成后的项目。

在本教程中,你学习了:

  • 对自动布局约束进行动画。
  • 将多个动画放入时间线以组合成复杂动画。
  • 用静态的截图模拟动画。
  • 用委托模式在两个对象之间创建弱绑定。

注意,静态截图的方法在卡片呈现时底层视图被改变的情况下无法使用,在这种情况下异步事件会导致一个刷新动作。

在开发中动画的代价是昂贵的,而且要做出满意的效果很难。但是,它常常是值得的,因为动画为 app 增加了亮点,能够让普通的 app 变得与众不同。

希望本教程能够激发你创建自己动画的灵感。有任何建议后疑问,或者分享你的创意,请加入到讨论中来!

猜你喜欢

转载自blog.csdn.net/kmyhy/article/details/79670878
now
今日推荐