作者:Tomasz Szulc,原文連結,原文日期:2018-09 譯者:Joeytat;校對:numbbbbb,WAMaker;定稿:Pancf
你也很喜歡常用 app 裡的那些小細節吧?當我從 dribbble 中尋找靈感時,就發現了這個漂亮的設計:當使用者在某個重要的檢視中修改設定或者進行了什麼操作時,會有煙花在周圍綻放。於是我就在想這個東西有多難實現,然後過了一段時間,我完成了 :)
煙花的細節
下面是對於這個效果的詳細描述。煙花應該在檢視周圍的某個特殊的位置爆開,可能是按鈕在點選事件響應時。當點選發生時,煙花應該在按鈕的四角爆開,並且爆炸產生的火花應該按照自身的軌跡移動。
超喜歡這個效果! 不僅讓我感受到視覺上的愉悅,還讓我想要不停地戳這個按鈕! :) ?
現在讓我們再看一眼這個動畫。每次生成的煙花,其整體行為是大致相似的。但還是在火花的軌跡和大小上有一些區別。讓我們拆開來說。
- 每一次點選都會產生兩處煙花,
- 每一處煙花會產生 8 個火花,
- 每個火花都遵循著自己的軌跡,
- 軌跡看起來相似,但其實不完全一樣。從爆炸開始的位置來看,有部分朝右,有部分朝左,剩餘的朝上或下。
火花的分佈
這個煙花特效有著簡單的火花分佈規則。將爆炸點分為四塊「視線區域」來看:上左,上右,下左,下右,每個區域都有兩個火花。
火花的軌跡
火花的移動有著自己的軌跡。在一處煙花中有 8 個火花,那至少需要 8 道軌跡。理想狀態下應該有更多的軌跡,可以增加一些隨機性,這樣連續爆發煙花的時候,不會看起來和前一個完全一樣。
我為每一個區域建立了 4 條軌跡,這樣就賦予了兩倍於火花數量的隨機性。為了方便計算,我統一了每條軌跡的初始點。因為我用了不同的工具來視覺化這些軌跡,所以圖上的軌跡和我完成的效果略有不同 - 但你能明白我的想法就行 :)
實現
理論足夠了。接下來讓我們把各個模組拼湊起來。
protocol SparkTrajectory {
/// 儲存著定義軌跡所需要的所有的點
var points: [CGPoint] { get set }
/// 用 path 來表現軌跡
var path: UIBezierPath { get }
}
複製程式碼
這是一個用於表示火花軌跡的協議。為了能夠更簡單地建立各式各樣的軌跡,我定義了這個通用介面協議,並且選擇基於三階 貝塞爾曲線 來實現軌跡;還新增了一個 init
方法,這樣我就可以通過一行程式碼來建立軌跡了。三階貝塞爾曲線必須包含四個點。第一個和最後一個點定義了軌跡的開始和結束的位置,中間的兩個點用於控制曲線的彎曲度。你可以用線上數學工具 desmos 來調整自己的貝塞爾曲線。
/// 擁有兩個控制點的貝塞爾曲線
struct CubicBezierTrajectory: SparkTrajectory {
var points = [CGPoint]()
init(_ x0: CGFloat, _ y0: CGFloat,
_ x1: CGFloat, _ y1: CGFloat,
_ x2: CGFloat, _ y2: CGFloat,
_ x3: CGFloat, _ y3: CGFloat) {
self.points.append(CGPoint(x: x0, y: y0))
self.points.append(CGPoint(x: x1, y: y1))
self.points.append(CGPoint(x: x2, y: y2))
self.points.append(CGPoint(x: x3, y: y3))
}
var path: UIBezierPath {
guard self.points.count == 4 else { fatalError("4 points required") }
let path = UIBezierPath()
path.move(to: self.points[0])
path.addCurve(to: self.points[3], controlPoint1: self.points[1], controlPoint2: self.points[2])
return path
}
}
複製程式碼
接下來要實現的是一個能夠建立隨機軌跡的工廠。前面的圖中你可以看到軌跡是根據顏色來分組的。我只建立了上右和下右兩塊位置的軌跡,然後進行了映象複製。這對於我們將要發射的煙花來說已經足夠了?
protocol SparkTrajectoryFactory {}
protocol ClassicSparkTrajectoryFactoryProtocol: SparkTrajectoryFactory {
func randomTopRight() -> SparkTrajectory
func randomBottomRight() -> SparkTrajectory
}
final class ClassicSparkTrajectoryFactory: ClassicSparkTrajectoryFactoryProtocol {
private lazy var topRight: [SparkTrajectory] = {
return [
CubicBezierTrajectory(0.00, 0.00, 0.31, -0.46, 0.74, -0.29, 0.99, 0.12),
CubicBezierTrajectory(0.00, 0.00, 0.31, -0.46, 0.62, -0.49, 0.88, -0.19),
CubicBezierTrajectory(0.00, 0.00, 0.10, -0.54, 0.44, -0.53, 0.66, -0.30),
CubicBezierTrajectory(0.00, 0.00, 0.19, -0.46, 0.41, -0.53, 0.65, -0.45),
]
}()
private lazy var bottomRight: [SparkTrajectory] = {
return [
CubicBezierTrajectory(0.00, 0.00, 0.42, -0.01, 0.68, 0.11, 0.87, 0.44),
CubicBezierTrajectory(0.00, 0.00, 0.35, 0.00, 0.55, 0.12, 0.62, 0.45),
CubicBezierTrajectory(0.00, 0.00, 0.21, 0.05, 0.31, 0.19, 0.32, 0.45),
CubicBezierTrajectory(0.00, 0.00, 0.18, 0.00, 0.31, 0.11, 0.35, 0.25),
]
}()
func randomTopRight() -> SparkTrajectory {
return self.topRight[Int(arc4random_uniform(UInt32(self.topRight.count)))]
}
func randomBottomRight() -> SparkTrajectory {
return self.bottomRight[Int(arc4random_uniform(UInt32(self.bottomRight.count)))]
}
}
複製程式碼
這裡先建立了用來表示火花軌跡工廠的抽象協議,還有一個我將其命名為經典煙花的火花軌跡的抽象協議,這樣的抽象可以方便後續將其替換成其他的軌跡協議。
如同我前面提到的,我通過 desmos 建立了兩組軌跡,對應著右上,和右下兩塊區域。
重要提醒:如果在 desmos 上 y 軸所顯示的是正數,那麼你應該將其轉換成負數。因為在 iOS 系統中,越接近螢幕頂部 y 軸的值越小,所以 y 軸的值需要翻轉一下。
並且值得一提的是,為了後面好計算,所有的軌跡初始點都是 (0,0)。
我們現在建立好了軌跡。接下來建立一些檢視來表示火花。對於經典煙花來說,只需要有顏色的圓圈就行。通過抽象可以讓我們在未來以更低的成本,建立不同的火花檢視。比如小鴨子圖片,或者是胖吉貓 :)
class SparkView: UIView {}
final class CircleColorSparkView: SparkView {
init(color: UIColor, size: CGSize) {
super.init(frame: CGRect(origin: .zero, size: size))
self.backgroundColor = color
self.layer.cornerRadius = self.frame.width / 2.0
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
}
extension UIColor {
static var sparkColorSet1: [UIColor] = {
return [
UIColor(red:0.89, green:0.58, blue:0.70, alpha:1.00),
UIColor(red:0.96, green:0.87, blue:0.62, alpha:1.00),
UIColor(red:0.67, green:0.82, blue:0.94, alpha:1.00),
UIColor(red:0.54, green:0.56, blue:0.94, alpha:1.00),
]
}()
}
複製程式碼
為了建立火花檢視,我們還需要一個工廠資料以填充,需要的資料是火花的大小,以及用來決定火花在哪個煙花的索引(用於增加隨機性)。
protocol SparkViewFactoryData {
var size: CGSize { get }
var index: Int { get }
}
protocol SparkViewFactory {
func create(with data: SparkViewFactoryData) -> SparkView
}
class CircleColorSparkViewFactory: SparkViewFactory {
var colors: [UIColor] {
return UIColor.sparkColorSet1
}
func create(with data: SparkViewFactoryData) -> SparkView {
let color = self.colors[data.index % self.colors.count]
return CircleColorSparkView(color: color, size: data.size)
}
}
複製程式碼
你看這樣抽象了之後,就算再實現一個像胖吉貓的火花也會很簡單。接下來讓我們來建立經典煙花。
typealias FireworkSpark = (sparkView: SparkView, trajectory: SparkTrajectory)
protocol Firework {
/// 煙花的初始位置
var origin: CGPoint { get set }
/// 定義了軌跡的大小. 軌跡都是統一大小
/// 所以需要在展示到螢幕上前將其放大
var scale: CGFloat { get set }
/// 火花的大小
var sparkSize: CGSize { get set }
/// 獲取軌跡
var trajectoryFactory: SparkTrajectoryFactory { get }
/// 獲取火花檢視
var sparkViewFactory: SparkViewFactory { get }
func sparkViewFactoryData(at index: Int) -> SparkViewFactoryData
func sparkView(at index: Int) -> SparkView
func trajectory(at index: Int) -> SparkTrajectory
}
extension Firework {
/// 幫助方法,用於返回火花檢視及對應的軌跡
func spark(at index: Int) -> FireworkSpark {
return FireworkSpark(self.sparkView(at: index), self.trajectory(at: index))
}
}
複製程式碼
這就是煙花的抽象。為了表示一個煙花需要這些東西:
- origin
- scale
- sparkSize
- trajectoryFactory
- sparkViewFactory
在我們實現協議之前,還有一個我之前沒有提到過的叫做按軌跡縮放的概念。當火花處於軌跡 <-1, 1> 或相似的位置時,我們希望它的大小會跟隨軌跡變化。我們還需要放大路徑以覆蓋更大的螢幕顯示效果。此外,我們還需要支援水平翻轉路徑,以方便我們實現經典煙花左側部分的軌跡,並且還要讓軌跡能朝某個指定方向偏移一點(增加隨機性)。下面是兩個能夠幫助我們達到目的的方法,我相信這段程式碼已經不需要更多描述了。
extension SparkTrajectory {
/// 縮放軌跡使其符合各種 UI 的要求
/// 在各種形變和 shift: 之前使用
func scale(by value: CGFloat) -> SparkTrajectory {
var copy = self
(0..<self.points.count).forEach { copy.points[$0].multiply(by: value) }
return copy
}
/// 水平翻轉軌跡
func flip() -> SparkTrajectory {
var copy = self
(0..<self.points.count).forEach { copy.points[$0].x *= -1 }
return copy
}
/// 偏移軌跡,在每個點上生效
/// 在各種形變和 scale: 和之後使用
func shift(to point: CGPoint) -> SparkTrajectory {
var copy = self
let vector = CGVector(dx: point.x, dy: point.y)
(0..<self.points.count).forEach { copy.points[$0].add(vector: vector) }
return copy
}
}
複製程式碼
好了,接下來就是實現經典煙花。
class ClassicFirework: Firework {
/**
x | x
x | x
|
---------------
x | x
x |
| x
**/
private struct FlipOptions: OptionSet {
let rawValue: Int
static let horizontally = FlipOptions(rawValue: 1 << 0)
static let vertically = FlipOptions(rawValue: 1 << 1)
}
private enum Quarter {
case topRight
case bottomRight
case bottomLeft
case topLeft
}
var origin: CGPoint
var scale: CGFloat
var sparkSize: CGSize
var maxChangeValue: Int {
return 10
}
var trajectoryFactory: SparkTrajectoryFactory {
return ClassicSparkTrajectoryFactory()
}
var classicTrajectoryFactory: ClassicSparkTrajectoryFactoryProtocol {
return self.trajectoryFactory as! ClassicSparkTrajectoryFactoryProtocol
}
var sparkViewFactory: SparkViewFactory {
return CircleColorSparkViewFactory()
}
private var quarters = [Quarter]()
init(origin: CGPoint, sparkSize: CGSize, scale: CGFloat) {
self.origin = origin
self.scale = scale
self.sparkSize = sparkSize
self.quarters = self.shuffledQuarters()
}
func sparkViewFactoryData(at index: Int) -> SparkViewFactoryData {
return DefaultSparkViewFactoryData(size: self.sparkSize, index: index)
}
func sparkView(at index: Int) -> SparkView {
return self.sparkViewFactory.create(with: self.sparkViewFactoryData(at: index))
}
func trajectory(at index: Int) -> SparkTrajectory {
let quarter = self.quarters[index]
let flipOptions = self.flipOptions(for: quarter)
let changeVector = self.randomChangeVector(flipOptions: flipOptions, maxValue: self.maxChangeValue)
let sparkOrigin = self.origin.adding(vector: changeVector)
return self.randomTrajectory(flipOptions: flipOptions).scale(by: self.scale).shift(to: sparkOrigin)
}
private func flipOptions(`for` quarter: Quarter) -> FlipOptions {
var flipOptions: FlipOptions = []
if quarter == .bottomLeft || quarter == .topLeft {
flipOptions.insert(.horizontally)
}
if quarter == .bottomLeft || quarter == .bottomRight {
flipOptions.insert(.vertically)
}
return flipOptions
}
private func shuffledQuarters() -> [Quarter] {
var quarters: [Quarter] = [
.topRight, .topRight,
.bottomRight, .bottomRight,
.bottomLeft, .bottomLeft,
.topLeft, .topLeft
]
var shuffled = [Quarter]()
for _ in 0..<quarters.count {
let idx = Int(arc4random_uniform(UInt32(quarters.count)))
shuffled.append(quarters[idx])
quarters.remove(at: idx)
}
return shuffled
}
private func randomTrajectory(flipOptions: FlipOptions) -> SparkTrajectory {
var trajectory: SparkTrajectory
if flipOptions.contains(.vertically) {
trajectory = self.classicTrajectoryFactory.randomBottomRight()
} else {
trajectory = self.classicTrajectoryFactory.randomTopRight()
}
return flipOptions.contains(.horizontally) ? trajectory.flip() : trajectory
}
private func randomChangeVector(flipOptions: FlipOptions, maxValue: Int) -> CGVector {
let values = (self.randomChange(maxValue), self.randomChange(maxValue))
let changeX = flipOptions.contains(.horizontally) ? -values.0 : values.0
let changeY = flipOptions.contains(.vertically) ? values.1 : -values.0
return CGVector(dx: changeX, dy: changeY)
}
private func randomChange(_ maxValue: Int) -> CGFloat {
return CGFloat(arc4random_uniform(UInt32(maxValue)))
}
}
複製程式碼
大多數程式碼都是 Firework
協議的實現,所以應該很容易理解。我們在各處傳遞了需要的工廠類,還新增了一個額外的列舉型別來隨機地為每個火花指定軌跡。
有少數幾個方法用來為煙花和火花增加隨機性。
還引入了一個 quarters
屬性,其中包含了火花的所有的方位。我們通過 shuffledQuarters:
來重新排列,以確保我們不會總是在相同的方位建立相同數量的火花。
好了,我們建立好了煙花,接下來怎麼讓火花動起來呢?這就引入了火花動畫啟動器的概念。
protocol SparkViewAnimator {
func animate(spark: FireworkSpark, duration: TimeInterval)
}
複製程式碼
這個方法接受一個包含火花檢視和其對應軌跡的元組 FireworkSpark
,以及動畫的持續時間。方法的實現取決於我們。我自己的實現蠻多的,但主要做了三件事情:讓火花檢視跟隨軌跡,同時縮放火花(帶有隨機性),修改其不透明度。簡單吧。同時得益於 SparkViewAnimator
的抽象度,我們還可以很簡單地將其替換成任何我們想要的動畫效果。
struct ClassicFireworkAnimator: SparkViewAnimator {
func animate(spark: FireworkSpark, duration: TimeInterval) {
spark.sparkView.isHidden = false // show previously hidden spark view
CATransaction.begin()
// 火花的位置
let positionAnim = CAKeyframeAnimation(keyPath: "position")
positionAnim.path = spark.trajectory.path.cgPath
positionAnim.calculationMode = kCAAnimationLinear
positionAnim.rotationMode = kCAAnimationRotateAuto
positionAnim.duration = duration
// 火花的縮放
let randomMaxScale = 1.0 + CGFloat(arc4random_uniform(7)) / 10.0
let randomMinScale = 0.5 + CGFloat(arc4random_uniform(3)) / 10.0
let fromTransform = CATransform3DIdentity
let byTransform = CATransform3DScale(fromTransform, randomMaxScale, randomMaxScale, randomMaxScale)
let toTransform = CATransform3DScale(CATransform3DIdentity, randomMinScale, randomMinScale, randomMinScale)
let transformAnim = CAKeyframeAnimation(keyPath: "transform")
transformAnim.values = [
NSValue(caTransform3D: fromTransform),
NSValue(caTransform3D: byTransform),
NSValue(caTransform3D: toTransform)
]
transformAnim.duration = duration
transformAnim.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
spark.sparkView.layer.transform = toTransform
// 火花的不透明度
let opacityAnim = CAKeyframeAnimation(keyPath: "opacity")
opacityAnim.values = [1.0, 0.0]
opacityAnim.keyTimes = [0.95, 0.98]
opacityAnim.duration = duration
spark.sparkView.layer.opacity = 0.0
// 組合動畫
let groupAnimation = CAAnimationGroup()
groupAnimation.animations = [positionAnim, transformAnim, opacityAnim]
groupAnimation.duration = duration
CATransaction.setCompletionBlock({
spark.sparkView.removeFromSuperview()
})
spark.sparkView.layer.add(groupAnimation, forKey: "spark-animation")
CATransaction.commit()
}
}
複製程式碼
現在的程式碼已經足夠讓我們在特定的檢視上展示煙花了。我又更進了一步,建立了一個 ClassicFireworkController
來處理所有的工作,這樣用一行程式碼就能啟動煙花。
這個煙花控制器還做了另一件事。它可以修改煙花的 zPosition
,這樣我們可以讓煙花一前一後地展示,效果更好看一些。
class ClassicFireworkController {
var sparkAnimator: SparkViewAnimator {
return ClassicFireworkAnimator()
}
func createFirework(at origin: CGPoint, sparkSize: CGSize, scale: CGFloat) -> Firework {
return ClassicFirework(origin: origin, sparkSize: sparkSize, scale: scale)
}
/// 讓煙花在其源檢視的角落附近爆開
func addFireworks(count fireworksCount: Int = 1,
sparks sparksCount: Int,
around sourceView: UIView,
sparkSize: CGSize = CGSize(width: 7, height: 7),
scale: CGFloat = 45.0,
maxVectorChange: CGFloat = 15.0,
animationDuration: TimeInterval = 0.4,
canChangeZIndex: Bool = true) {
guard let superview = sourceView.superview else { fatalError() }
let origins = [
CGPoint(x: sourceView.frame.minX, y: sourceView.frame.minY),
CGPoint(x: sourceView.frame.maxX, y: sourceView.frame.minY),
CGPoint(x: sourceView.frame.minX, y: sourceView.frame.maxY),
CGPoint(x: sourceView.frame.maxX, y: sourceView.frame.maxY),
]
for _ in 0..<fireworksCount {
let idx = Int(arc4random_uniform(UInt32(origins.count)))
let origin = origins[idx].adding(vector: self.randomChangeVector(max: maxVectorChange))
let firework = self.createFirework(at: origin, sparkSize: sparkSize, scale: scale)
for sparkIndex in 0..<sparksCount {
let spark = firework.spark(at: sparkIndex)
spark.sparkView.isHidden = true
superview.addSubview(spark.sparkView)
if canChangeZIndex {
let zIndexChange: CGFloat = arc4random_uniform(2) == 0 ? -1 : +1
spark.sparkView.layer.zPosition = sourceView.layer.zPosition + zIndexChange
} else {
spark.sparkView.layer.zPosition = sourceView.layer.zPosition
}
self.sparkAnimator.animate(spark: spark, duration: animationDuration)
}
}
}
private func randomChangeVector(max: CGFloat) -> CGVector {
return CGVector(dx: self.randomChange(max: max), dy: self.randomChange(max: max))
}
private func randomChange(max: CGFloat) -> CGFloat {
return CGFloat(arc4random_uniform(UInt32(max))) - (max / 2.0)
}
}
複製程式碼
這個控制器只做了幾件事情。隨機選擇了一個角落展示煙花。在煙花出現的位置,煙花和火花的數量上增加了一些隨機性。然後將火花新增到目標檢視上,如果需要的話還會調整 zIndex
,最後啟動了動畫。
幾乎所有的引數都設定了預設引數,所以你可以不管他們。直接通過你的控制器呼叫這個:
self.fireworkController.addFireworks(count: 2, sparks: 8, around: button)
複製程式碼
然後,哇!
從這一步起,新新增一個像下面這樣的煙花就變得非常簡單了。你只需要定義新的軌跡,建立一個新的煙花,並且按照你希望的樣子來實現即可。將這些程式碼放入一個控制器可以讓你想在哪裡啟動煙花都很簡單 :) 或者你也可以直接使用這個噴泉煙花,我已經把它放在了我的 github 專案 tomkowz/fireworks 中。
總結
這個動畫效果的實現並不簡單但也不算很難。通過對問題(在我們的情況下是動畫效果)的正確分析,我們可以將其分解成多個小問題,逐個解決然後將其組合在一起。真希望我有機會能夠在未來的的專案中使用這個效果?
好啦這就是今天的內容。感謝閱讀!
本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權,最新文章請訪問 swift.gg。