給 UIView 來點菸花

做者:Tomasz Szulc,原文連接,原文日期:2018-09 譯者:Joeytat;校對:numbbbbbWAMaker;定稿:Pancfgit

你也很喜歡經常使用 app 裏的那些小細節吧?當我從 dribbble 中尋找靈感時,就發現了這個漂亮的設計:當用戶在某個重要的視圖中修改設置或者進行了什麼操做時,會有煙花在周圍綻開。因而我就在想這個東西有多難實現,而後過了一段時間,我完成了 :)github

hero

煙花的細節

下面是對於這個效果的詳細描述。煙花應該在視圖周圍的某個特殊的位置爆開,多是按鈕在點擊事件響應時。當點擊發生時,煙花應該在按鈕的四角爆開,而且爆炸產生的火花應該按照自身的軌跡移動。swift

final

超喜歡這個效果! 不只讓我感覺到視覺上的愉悅,還讓我想要不停地戳這個按鈕! :) 🎉app

如今讓咱們再看一眼這個動畫。每次生成的煙花,其總體行爲是大體類似的。但仍是在火花的軌跡和大小上有一些區別。讓咱們拆開來講。iview

  • 每一次點擊都會產生兩處煙花
  • 每一處煙花會產生 8 個火花
  • 每一個火花都遵循着本身的軌跡
  • 軌跡看起來類似,但其實不徹底同樣。從爆炸開始的位置來看,有部分朝,有部分朝,剩餘的朝

火花的分佈

這個煙花特效有着簡單的火花分佈規則。將爆炸點分爲四塊「視線區域」來看:上左,上右,下左,下右,每一個區域都有兩個火花。dom

sparks distribution

火花的軌跡

火花的移動有着本身的軌跡。在一處煙花中有 8 個火花,那至少須要 8 道軌跡。理想狀態下應該有更多的軌跡,能夠增長一些隨機性,這樣連續爆發煙花的時候,不會看起來和前一個徹底同樣。工具

spark-trajectories

我爲每個區域建立了 4 條軌跡,這樣就賦予了兩倍於火花數量的隨機性。爲了方便計算,我統一了每條軌跡的初始點。由於我用了不一樣的工具來可視化這些軌跡,因此圖上的軌跡和我完成的效果略有不一樣 - 但你能明白個人想法就行 :)動畫

實現

理論足夠了。接下來讓咱們把各個模塊拼湊起來。ui

protocol SparkTrajectory {

    /// 存儲着定義軌跡所須要的全部的點
    var points: [CGPoint] { get set }

    /// 用 path 來表現軌跡
    var path: UIBezierPath { get }
}
複製代碼

這是一個用於表示火花軌跡的協議。爲了可以更簡單地建立各式各樣的軌跡,我定義了這個通用接口協議,而且選擇基於三階 貝塞爾曲線 來實現軌跡;還添加了一個 init 方法,這樣我就能夠經過一行代碼來建立軌跡了。三階貝塞爾曲線必須包含四個點。第一個和最後一個點定義了軌跡的開始和結束的位置,中間的兩個點用於控制曲線的彎曲度。你能夠用在線數學工具 desmos 來調整本身的貝塞爾曲線。spa

/// 擁有兩個控制點的貝塞爾曲線
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
    }
}
複製代碼

desmos-tool

接下來要實現的是一個可以建立隨機軌跡的工廠。前面的圖中你能夠看到軌跡是根據顏色來分組的。我只建立了上右和下右兩塊位置的軌跡,而後進行了鏡像複製。這對於咱們將要發射的煙花來講已經足夠了🚀

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)
複製代碼

而後,哇!

classic

從這一步起,新添加一個像下面這樣的煙花就變得很是簡單了。你只須要定義新的軌跡,建立一個新的煙花,而且按照你但願的樣子來實現便可。將這些代碼放入一個控制器可讓你想在哪裏啓動煙花都很簡單 :) 或者你也能夠直接使用這個噴泉煙花,我已經把它放在了個人 github 項目 tomkowz/fireworks 中。

fountain

總結

這個動畫效果的實現並不簡單但也不算很難。經過對問題(在咱們的狀況下是動畫效果)的正確分析,咱們能夠將其分解成多個小問題,逐個解決而後將其組合在一塊兒。真但願我有機會可以在將來的的項目中使用這個效果🎉

好啦這就是今天的內容。感謝閱讀!

本文由 SwiftGG 翻譯組翻譯,已經得到做者翻譯受權,最新文章請訪問 swift.gg

相關文章
相關標籤/搜索