iOS開源:SDiffuseMenu - 炫酷菜單彈射動畫

JacU79 7年前發布 | 14K 次閱讀 iOS開發 移動開發

配置圖如下: 

版本記錄

  • V1.2.1 修復代碼,以便更好的支持 CocoaPods

  • V1.2.0 支持 CocoaPods 嵌入代碼因訪問權限問題致部分功能無法使用,已在1.2.1版修復

  • V1.1.0 新增任意方向的直線彈出動畫\新增常用方向的枚舉..

  • 更多記錄 請戳一下

一、使用方法:

1.使用 pod 方式嵌入項目: pod 'SDiffuseMenu','~> 1.2.1'

2.直接下載 zip 包內含:

1)SDiffuseMenuDebugDemo.xcodeproj: 調試 demo

2)SDiffuseMenu 文件夾:內含源文件

3)SDiffuseMenuDemo.xcworkspace:CocoaPods 調試 demo ,位于Source 文件夾內

添加協議(動畫狀態回調) -> 設置選項數組 -> 設置菜單按鈕 -> 動畫屬性配置 -> .addSubview(menu)

1、添加協議

class ViewController: UIViewController, SDiffuseMenuDelegate {
    var menu: SDiffuseMenu!
}

2、設置菜單的選項按鈕數據

// 加載圖片
guard let storyMenuItemImage            =  UIImage(named:"menuitem-normal.png")         else { fatalError("圖片加載失敗") }
guard let storyMenuItemImagePressed     =  UIImage(named:"menuitem-highlighted.png")    else { fatalError("圖片加載失敗") }
guard let starImage                     =  UIImage(named:"star.png")                    else { fatalError("圖片加載失敗") }
guard let starItemNormalImage           =  UIImage(named:"addbutton-normal.png")        else { fatalError("圖片加載失敗") }
guard let starItemLightedImage          =  UIImage(named:"addbutton-highlighted.png")   else { fatalError("圖片加載失敗") }
guard let starItemContentImage          =  UIImage(named:"plus-normal.png")             else { fatalError("圖片加載失敗") }
guard let starItemContentLightedImage   =  UIImage(named:"plus-highlighted.png")        else { fatalError("圖片加載失敗") }

var menus = [SDiffuseMenuItem]()

for _ in 0 ..< 6 {
    let starMenuItem =  SDiffuseMenuItem(image: storyMenuItemImage,
                                         highlightedImage: storyMenuItemImagePressed, 
                                         contentImage: starImage,
                                         highlightedContentImage: nil)
    menus.append(starMenuItem)
}

3、設置菜單按鈕

let startItem = SDiffuseMenuItem(image: starItemNormalImage,
                                 highlightedImage: starItemLightedImage,
                                 contentImage: starItemContentImage,
                                 highlightedContentImage: starItemContentLightedImage)

4、添加 SDiffuseMenu

let menuRect  = CGRect.init(x: self.menuView.bounds.size.width/2,
                           y: self.menuView.bounds.size.width/2,
                           width: self.menuView.bounds.size.width,
                           height: self.menuView.bounds.size.width)
menu          =  SDiffuseMenu(frame: menuRect,
                          startItem: startItem,
                         menusArray: menus as NSArray,
                          grapyType: SDiffuseMenu.SDiffuseMenuGrapyType.arc)
menu.center   = self.menuView.center
menu.delegate = self
self.menuView.addSubview(menu)

5、動畫配置

  • 如果配置弧線形動畫,則動畫中弧線半徑變化為:0--> 最大 farRadius--> 最小 nearRadius--> 結束 endRadius

  • 如果配置直線形動畫,則動畫中半徑就是直線段的長度,變化為:0--> 最大 farRadius--> 最小 nearRadius-->結束 endRadius

// 動畫時長
menu.animationDuration  = CFTimeInterval(animationDrationValue.text!)
// 最小半徑
menu.nearRadius         = CGFloat((nearRadiusValue.text! as NSString).floatValue)
// 結束半徑
menu.endRadius          = CGFloat((endRadiusValue.text! as NSString).floatValue)
// 最大半徑
menu.farRadius          = CGFloat((farRadiusValue.text! as NSString).floatValue)
// 單個動畫間隔時間
menu.timeOffset         = CFTimeInterval(timeOffSetValue.text!)!
// 整體角度
menu.menuWholeAngle     = CGFloat((menuWholeAngleValue.text! as NSString).floatValue)
// 整體偏移角度
menu.rotateAngle        = CGFloat((rotateAngleValue.text! as NSString).floatValue)
// 展開時自旋角度
menu.expandRotation     = CGFloat(M_PI)
// 結束時自旋角度
menu.closeRotation      = CGFloat(M_PI * 2)
// 是否旋轉菜單按鈕
menu.rotateAddButton    = rotateAddButton.isOn
// 菜單按鈕旋轉角度
menu.rotateAddButtonAngle = CGFloat((rotateAddButtonAngleValue.text! as NSString).floatValue)
// 菜單展示的形狀:直線 or 弧形
menu.sDiffuseMenuGrapyType = isLineGrapyType.isOn == true ? .line : .arc

// 為方便使用,V1.1.0版本已枚舉常見方位,可直接使用,無需再次設置 rotateAngle && menuWholeAngle
// 若對于 rotateAngle\menuWholeAngle 不熟悉,建議查看 source 目錄下的配置圖片
menu.sDiffuseMenuDirection = .above // 上方180°
//        menu.sDiffuseMenuDirection = .left // 左方180°
//        menu.sDiffuseMenuDirection = .below // 下方180°
//        menu.sDiffuseMenuDirection = .right // 右方180°
//        menu.sDiffuseMenuDirection = .upperRight // 右上方90°
//        menu.sDiffuseMenuDirection = .lowerRight // 右下方90°
//        menu.sDiffuseMenuDirection = .upperLeft // 左上方90°
//        menu.sDiffuseMenuDirection = .lowerLeft // 左下方90°

6、動畫過程監聽

func SDiffuseMenuDidSelectMenuItem(_ menu: SDiffuseMenu, didSelectIndex index: Int) {
    print("選中按鈕 at index:\(index) is: \(menu.menuItemAtIndex(index)) ")
}

func SDiffuseMenuDidClose(_ menu: SDiffuseMenu) {
    print("菜單關閉動畫結束")
}

func SDiffuseMenuDidOpen(_ menu: SDiffuseMenu) {
    print("菜單展開動畫結束")
}

func SDiffuseMenuWillOpen(_ menu: SDiffuseMenu) {
    print("菜單將要展開")
}

func SDiffuseMenuWillClose(_ menu: SDiffuseMenu) {
    print("菜單將要關閉")
}

二、Swift轉寫之旅

總的來說,動畫的原理還是比較簡單的,主要涉及到的知識點是 CABasicAnimation、CAKeyframeAnimation 以及事件響應鏈相關知識,下邊分兩部分介紹

1、CAPropertyAnimation動畫

在 SDiffuseMenu 中動畫用 CAPropertyAnimation 的子類 CABasicAnimation 和 CAKeyframeAnimation 來實現,關于這兩個子類簡述如下:

  • CABasicAnimation 其實可以看作是一種特殊的關鍵幀動畫,只有頭尾兩個關鍵幀,可實現移動、旋轉、縮放等基本動畫;

  • CAKeyframeAnimation 則可以支持任意多個關鍵幀,關鍵幀有兩種方式來指定,使用path或values;

  • - path 可以是 CGPathRef、CGMutablePathRef 或者貝塞爾曲線,注意的是:設置了 path 之后 values 就無效了;values 則相對靈活, 可以指定任意關鍵幀幀值;

  • - keyTimes 可以為 values 中的關鍵幀設置一一對應對應的時間點,其取值范圍為0到1.0,keyTimes 沒有設置的時候,各個關鍵幀的時間是平分的;

  • - ..

更多的動畫知識請戳此處 CoreAnimation_guide

相關的指南、示例代碼可以通過點擊頁面右上角搜索按鈕進行搜索,官方文檔大多點到為止,挺適合入門學習的,更深的還需要在實踐中摸索總結

2、動畫分析

在 V1.1.0 版本中,已擴展動畫的形狀:新加入直線型,其原理及計算方法同弧線形,下文不做過多介紹,詳情參見版本記錄

不論多么復雜的動畫,都是由簡單的動畫組成的,大家先看下 SDiffuseMenu 中單選項動畫:

仔細分析發現可以將整個動畫可以拆分為三大部分:

  • 菜單按鈕的自旋轉,通過 transform 屬性即可實現;

  • 選項按鈕的整體展開動畫,實際是在定時器中依次添加單個選項按鈕的動畫組,控制 timeInterval 來實現動畫的先后執行順序;

  • 單個選項按鈕的動畫則拆分為3部分:展開動畫、結束動畫和點擊動畫,都是動畫組,下邊以結束動畫為例,簡單介紹其實現過程

2.1單個選項關閉動畫分析:

單選項按鈕關閉動畫過程如下:

  • 自旋

大家仔細看會發現展開動畫和結束動畫的自旋轉是有差異的,因為關鍵幀設置的不同

展開動畫中設置的關鍵幀如下,0.1對應展開角度0°,0.3對應 expandRotation 自旋角度,0.4對應0°,所以在0.3 -> 0.4的時間會出現較快速的自旋

rotateAnimation.values   = [CGFloat(0.0),
                           CGFloat(expandRotation),
                           CGFloat(0.0)]

rotateAnimation.keyTimes = [NSNumber(value: 0.1 as Float),
                           NSNumber(value: 0.3 as Float),
                           NSNumber(value: 0.4 as Float)]

而關閉的動畫中,設置為0 -> 0.4 慢速自旋,0.4 -> 0.5 快速自旋

rotateAnimation.values   = [CGFloat(0.0),
                           CGFloat(closeRotation),
                           CGFloat(0.0)]

rotateAnimation.keyTimes = [NSNumber(value: 0.0 as Float),
                           NSNumber(value: 0.4 as Float),
                           NSNumber(value: 0.5 as Float)]
  • 移動

移動的控制在于 path 是怎樣設定的,代碼中我寫了兩種方法,其中一種被注釋掉

let positionAnimation      =  CAKeyframeAnimation(keyPath: "position")
positionAnimation.duration = animationDuration

1)使用貝塞爾曲線作為 path,從代碼中可以明顯的看出移動的路徑: endPoint -> farPoint -> startPoint

let path = UIBezierPath.init()
path.move(to: CGPoint(x: item.endPoint.x, y: item.endPoint.y))
path.addLine(to: CGPoint(x: item.farPoint.x, y: item.farPoint.y))
path.addLine(to: CGPoint(x: item.startPoint.x, y: item.startPoint.y))
positionAnimation.path = path.cgPath

2).使用 CGPathRef 或 GCMutablePathRef 設置路徑

let path =  CGMutablePath()
path.move(to: CGPoint(x: item.endPoint.x, y: item.endPoint.y))
path.addLine(to: CGPoint(x: item.farPoint.x, y: item.farPoint.y))
path.addLine(to: CGPoint(x: item.startPoint.x, y: item.startPoint.y))
positionAnimation.path = path

自旋和平移都有了,接下來要加入到動畫組中:

let animationgroup              =  CAAnimationGroup()
animationgroup.animations       = [positionAnimation, rotateAnimation]
animationgroup.duration         = animationDuration
// 動畫結束后,layer保持最終的狀態
animationgroup.fillMode         = kCAFillModeForwards
// 速度控制我設置的如此,大家根據需要自行修改即可
animationgroup.timingFunction   = CAMediaTimingFunction(name:kCAMediaTimingFunctionEaseIn)
// 代理是為了獲取到動畫結束的信號
animationgroup.delegate         = self

最添加進 layer 即可

item.layer.add(animationgroup,forKey: "Close")

其余的動畫原理和上述的關閉動畫其實是一樣的,基于屬性的動畫,通過操作幀來實現我們想要的效果,小伙伴們直接看代碼吧~

2.2整體動畫的控制

注意,整體動畫的控制以上并未表述,在這個地方也需要注意下,為了讓整體動畫在一個合適的角度展示出來,就需要從整體上控制角度

從上圖中可以看出,整體的角度是由 menuWholeAngle 和 rotateAngle 共同控制的

  • menuWholeAngle: 控制整體動畫的范圍角度;

  • rotateAngle: 用于控制整體的偏移角度

為了方便理解整體角度的控制,我以結束位置為例畫了CAD圖,如下:

提醒:下文所述的坐標計算都是基于笛卡兒坐標系,注意與UIKit中坐標系的異同。

關于上圖,說明如下:

  • 圖中有5個選項按鈕和一個菜單按鈕,整體角度是 menuWholeAngle,選項中心夾角β(見代碼注釋);

  • 假設偏移角度 rotateAngle=0,則以紅色線為坐標軸XY,下文先以此為準進行坐標計算;

  • 假設整體偏移角度 rotateAngle!=0,那么以綠為坐標軸XY,其中偏移角度就是 rotateAngle

// 
// β = ti * menuWholeAngle / icount - CGFloat(1.0)
// β 是兩個選項按鈕的中心夾角
// 計算 β 正弦余弦值
let sinValue  = CGFloat(sinf(Float(ti * menuWholeAngle / icount - CGFloat(1.0))))
let cosValue  = CGFloat(cosf(Float(ti * menuWholeAngle / icount - CGFloat(1.0) )))

// 結束點坐標
var x         = startPoint.x + CGFloat(endRadius) * sinValue
var y         = (CGFloat(startPoint.y) - endRadius * cosValue)
let endPoint  =  CGPoint(x: x,y: y)
item.endPoint = endPoint // _rotateCGPointAroundCenter(endPoint, center: startPoint, angle: rotateAngle)

// 最近點坐標,計算方法同CAD圖中的結束點坐標
x = startPoint.x + nearRadius * CGFloat(sinValue)
y = startPoint.y - nearRadius * CGFloat(cosValue)
let nearPoint  =  CGPoint(x: x, y: y)
item.nearPoint = nearPoint // _rotateCGPointAroundCenter(nearPoint, center: startPoint, angle: rotateAngle)

// 最遠點坐標,計算方法同CAD圖中的結束點坐標
let farPoint   =  CGPoint(x: startPoint.x + farRadius * sinValue, y: startPoint.y - farRadius * cosValue)
item.farPoint  = farPoint //  _rotateCGPointAroundCenter(farPoint, center: startPoint, angle: rotateAngle)

OK,上邊計算了每個選項的坐標,從而確定了每個選項的 end 坐標,可以實現一個整體的動畫效果。但是,請注意,上邊我注釋了對 '_rotateCGPointAroundCenter '的調用,使得動畫的整體偏移角度為0。如果放開注釋,結果會怎樣?

最終我們要實現的效果是可以圍繞菜單選項展開任意角度的整體動畫,那么只需要在以上的基礎,加上坐標軸系的旋轉即可。請看上圖的綠色線,假設其為新的坐標系,讓紅色坐標系繞其旋轉 rotateAngle,就相當于選項按鈕整體偏移 rotateAngle,這樣就可以做到任意方向的動畫,如下圖:

偏移代碼如下:

private func _rotateCGPointAroundCenter( _ point: CGPoint, center: CGPoint, angle: CGFloat) -> CGPoint {
    let translation     = CGAffineTransform(translationX: center.x, y: center.y)
    let rotation        = CGAffineTransform(rotationAngle: angle)
    let transformGroup  = translation.inverted().concatenating(rotation).concatenating(translation)
    return point.applying(transformGroup)
}

那些看似復雜的動畫,但如果細細分析,其實也不難哦~

3、事件響應鏈

其實這里并沒有直接使用 hitTest 尋找響應 View,而是在兩處使用相關的知識

3.1 利用'point(inside point: CGPoint, with event: UIEvent?) -> Bool'來控制 touch 事件的分發

override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
    // 動畫中禁止 touch
    if (_isAnimating) {
        return false
    }
    // 展開時可以 touch 任意按鈕
    else if (true == expanding) {
        return true
    } 
    // 除上述情況外,僅菜單按鈕可點擊
    else {
        return _startButton.frame.contains(point)
    }
}

3.2 增大按鈕的點擊區域

在OC中,經常遇到放大按鈕點擊區域或者限制 touch 區域的問題,一般可以通過設置 frame 或者利用 hitTest 處理,在 Swift 中也是一樣的。在 SDiffuseMenu 中,對于點擊范圍的處理如下:

override func touchesEnded(_ touches: Set, with event: UIEvent?) {
    self.isHighlighted = false
    let location = ((touches as NSSet).anyObject()! as AnyObject).location(in: self) // 點擊范圍
    if (SDiffuseMenuItem.ScaleRect(self.bounds, n: kDiffuseMenuItemDefaultTouchRange).contains(location)) {
        delegate?.SDiffuseMenuItemTouchesEnd(self)

    }
}
class func ScaleRect( _ rect:CGRect, n:CGFloat) -> CGRect {
    let x       = (rect.size.width - rect.size.width * n) / 2
    let y       = (rect.size.height - rect.size.height * n) / 2
    let width   = rect.size.width * n
    let height  = rect.size.height * n

    return CGRect(x: x , y: y ,width: width ,height: height)
}
// 其中ScaleRect方法的playground版見下圖

// 增大點擊范圍,還可以在point方法中判斷,不過就需要SDiffuseMenu.swift跟著調整了

下圖是 Playground 中 ScaleRect方法小測試,看著是不是很好用啊

喜歡的朋友還請給個star哦,后續我會持續優化的!

 

 本文由用戶 JacU79 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!