iOS开源:SDiffuseMenu - 炫酷菜单弹射动画
JacU79
7年前
<p style="text-align:center"><img src="https://simg.open-open.com/show/18d3a20932f762ea2dc474cb6cb5cb1f.gif"></p> <p>配置图如下: </p> <p style="text-align:center"><img src="https://simg.open-open.com/show/0f5c701beaabeac48dc881ca434a9a9f.png"></p> <p>版本记录</p> <ul> <li> <p>V1.2.1 修复代码,以便更好的支持 CocoaPods</p> </li> <li> <p>V1.2.0 支持 CocoaPods 嵌入代码因访问权限问题致部分功能无法使用,已在1.2.1版修复</p> </li> <li> <p>V1.1.0 新增任意方向的直线弹出动画\新增常用方向的枚举..</p> </li> <li> <p>更多记录 <a href="/misc/goto?guid=4959735682247095228" rel="nofollow,noindex">请戳一下</a></p> </li> </ul> <p>一、使用方法:</p> <p>1.使用 pod 方式嵌入项目: pod 'SDiffuseMenu','~> 1.2.1'</p> <p>2.直接下载 zip 包内含:</p> <p>1)SDiffuseMenuDebugDemo.xcodeproj: 调试 demo</p> <p>2)SDiffuseMenu 文件夹:内含源文件</p> <p>3)SDiffuseMenuDemo.xcworkspace:CocoaPods 调试 demo ,位于Source 文件夹内</p> <p>添加协议(动画状态回调) -> 设置选项数组 -> 设置菜单按钮 -> 动画属性配置 -> .addSubview(menu)</p> <p>1、添加协议</p> <pre> <code class="language-objectivec">class ViewController: UIViewController, SDiffuseMenuDelegate { var menu: SDiffuseMenu! }</code></pre> <p>2、设置菜单的选项按钮数据</p> <pre> <code class="language-objectivec">// 加载图片 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) }</code></pre> <p>3、设置菜单按钮</p> <pre> <code class="language-objectivec">let startItem = SDiffuseMenuItem(image: starItemNormalImage, highlightedImage: starItemLightedImage, contentImage: starItemContentImage, highlightedContentImage: starItemContentLightedImage)</code></pre> <p>4、添加 SDiffuseMenu</p> <pre> <code class="language-objectivec">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)</code></pre> <p>5、动画配置</p> <ul> <li> <p>如果配置弧线形动画,则动画中弧线半径变化为:0--> 最大 farRadius--> 最小 nearRadius--> 结束 endRadius</p> </li> <li> <p>如果配置直线形动画,则动画中半径就是直线段的长度,变化为:0--> 最大 farRadius--> 最小 nearRadius-->结束 endRadius</p> </li> </ul> <pre> <code class="language-objectivec">// 动画时长 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°</code></pre> <p>6、动画过程监听</p> <pre> <code class="language-objectivec">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("菜单将要关闭") }</code></pre> <p>二、Swift转写之旅</p> <p>总的来说,动画的原理还是比较简单的,主要涉及到的知识点是 CABasicAnimation、CAKeyframeAnimation 以及事件响应链相关知识,下边分两部分介绍</p> <p>1、CAPropertyAnimation动画</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/d42a2615cebc3c09760af194b378847c.png"></p> <p>在 SDiffuseMenu 中动画用 CAPropertyAnimation 的子类 CABasicAnimation 和 CAKeyframeAnimation 来实现,关于这两个子类简述如下:</p> <ul> <li> <p>CABasicAnimation 其实可以看作是一种特殊的关键帧动画,只有头尾两个关键帧,可实现移动、旋转、缩放等基本动画;</p> </li> <li> <p>CAKeyframeAnimation 则可以支持任意多个关键帧,关键帧有两种方式来指定,使用path或values;</p> </li> <li> <p>- path 可以是 CGPathRef、CGMutablePathRef 或者贝塞尔曲线,注意的是:设置了 path 之后 values 就无效了;values 则相对灵活, 可以指定任意关键帧帧值;</p> </li> <li> <p>- keyTimes 可以为 values 中的关键帧设置一一对应对应的时间点,其取值范围为0到1.0,keyTimes 没有设置的时候,各个关键帧的时间是平分的;</p> </li> <li> <p>- ..</p> </li> </ul> <p>更多的动画知识请戳此处 <a href="/misc/goto?guid=4959735682337798314" rel="nofollow,noindex">CoreAnimation_guide</a></p> <p>相关的指南、示例代码可以通过点击页面右上角搜索按钮进行搜索,官方文档大多点到为止,挺适合入门学习的,更深的还需要在实践中摸索总结</p> <p>2、动画分析</p> <p>在 V1.1.0 版本中,已扩展动画的形状:新加入直线型,其原理及计算方法同弧线形,下文不做过多介绍,详情参见版本记录</p> <p>不论多么复杂的动画,都是由简单的动画组成的,大家先看下 SDiffuseMenu 中单选项动画:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/977bacf891d0370b82f994310889d4a6.gif"></p> <p>仔细分析发现可以将整个动画可以拆分为三大部分:</p> <ul> <li> <p>菜单按钮的自旋转,通过 transform 属性即可实现;</p> </li> <li> <p>选项按钮的整体展开动画,实际是在定时器中依次添加单个选项按钮的动画组,控制 timeInterval 来实现动画的先后执行顺序;</p> </li> <li> <p>单个选项按钮的动画则拆分为3部分:展开动画、结束动画和点击动画,都是动画组,下边以结束动画为例,简单介绍其实现过程</p> </li> </ul> <p>2.1单个选项关闭动画分析:</p> <p>单选项按钮关闭动画过程如下:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/b6818678c0443d67ae32736662ac8ffa.png"></p> <ul> <li> <p>自旋</p> </li> </ul> <p>大家仔细看会发现展开动画和结束动画的自旋转是有差异的,因为关键帧设置的不同</p> <p>展开动画中设置的关键帧如下,0.1对应展开角度0°,0.3对应 expandRotation 自旋角度,0.4对应0°,所以在0.3 -> 0.4的时间会出现较快速的自旋</p> <pre> <code class="language-objectivec">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)]</code></pre> <p>而关闭的动画中,设置为0 -> 0.4 慢速自旋,0.4 -> 0.5 快速自旋</p> <pre> <code class="language-objectivec">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)]</code></pre> <ul> <li> <p>移动</p> </li> </ul> <p>移动的控制在于 path 是怎样设定的,代码中我写了两种方法,其中一种被注释掉</p> <pre> <code class="language-objectivec">let positionAnimation = CAKeyframeAnimation(keyPath: "position") positionAnimation.duration = animationDuration</code></pre> <p>1)使用贝塞尔曲线作为 path,从代码中可以明显的看出移动的路径: endPoint -> farPoint -> startPoint</p> <pre> <code class="language-objectivec">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</code></pre> <p>2).使用 CGPathRef 或 GCMutablePathRef 设置路径</p> <pre> <code class="language-objectivec">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</code></pre> <p>自旋和平移都有了,接下来要加入到动画组中:</p> <pre> <code class="language-objectivec">let animationgroup = CAAnimationGroup() animationgroup.animations = [positionAnimation, rotateAnimation] animationgroup.duration = animationDuration // 动画结束后,layer保持最终的状态 animationgroup.fillMode = kCAFillModeForwards // 速度控制我设置的如此,大家根据需要自行修改即可 animationgroup.timingFunction = CAMediaTimingFunction(name:kCAMediaTimingFunctionEaseIn) // 代理是为了获取到动画结束的信号 animationgroup.delegate = self</code></pre> <p>最添加进 layer 即可</p> <pre> <code class="language-objectivec">item.layer.add(animationgroup,forKey: "Close")</code></pre> <p>其余的动画原理和上述的关闭动画其实是一样的,基于属性的动画,通过操作帧来实现我们想要的效果,小伙伴们直接看代码吧~</p> <p>2.2整体动画的控制</p> <p>注意,整体动画的控制以上并未表述,在这个地方也需要注意下,为了让整体动画在一个合适的角度展示出来,就需要从整体上控制角度</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/f5c7ba17ab20a3bff15d42387100a667.png"></p> <p>从上图中可以看出,整体的角度是由 menuWholeAngle 和 rotateAngle 共同控制的</p> <ul> <li> <p>menuWholeAngle: 控制整体动画的范围角度;</p> </li> <li> <p>rotateAngle: 用于控制整体的偏移角度</p> </li> </ul> <p>为了方便理解整体角度的控制,我以结束位置为例画了CAD图,如下:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/00a3330e7f15ca3f060ca6677bd5b25f.png"></p> <p>提醒:下文所述的坐标计算都是基于笛卡儿坐标系,注意与UIKit中坐标系的异同。</p> <p>关于上图,说明如下:</p> <ul> <li> <p>图中有5个选项按钮和一个菜单按钮,整体角度是 menuWholeAngle,选项中心夹角β(见代码注释);</p> </li> <li> <p>假设偏移角度 rotateAngle=0,则以红色线为坐标轴XY,下文先以此为准进行坐标计算;</p> </li> <li> <p>假设整体偏移角度 rotateAngle!=0,那么以绿为坐标轴XY,其中偏移角度就是 rotateAngle</p> </li> </ul> <pre> <code class="language-objectivec">// // β = 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)</code></pre> <p>OK,上边计算了每个选项的坐标,从而确定了每个选项的 end 坐标,可以实现一个整体的动画效果。但是,请注意,上边我注释了对 '_rotateCGPointAroundCenter '的调用,使得动画的整体偏移角度为0。如果放开注释,结果会怎样?</p> <p>最终我们要实现的效果是可以围绕菜单选项展开任意角度的整体动画,那么只需要在以上的基础,加上坐标轴系的旋转即可。请看上图的绿色线,假设其为新的坐标系,让红色坐标系绕其旋转 rotateAngle,就相当于选项按钮整体偏移 rotateAngle,这样就可以做到任意方向的动画,如下图:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/e514f37868c05fe9b54efddaae0a8b4c.png"></p> <p>偏移代码如下:</p> <pre> <code class="language-objectivec">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) }</code></pre> <p>那些看似复杂的动画,但如果细细分析,其实也不难哦~</p> <p>3、事件响应链</p> <p>其实这里并没有直接使用 hitTest 寻找响应 View,而是在两处使用相关的知识</p> <p>3.1 利用'point(inside point: CGPoint, with event: UIEvent?) -> Bool'来控制 touch 事件的分发</p> <pre> <code class="language-objectivec">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) } }</code></pre> <p>3.2 增大按钮的点击区域</p> <p>在OC中,经常遇到放大按钮点击区域或者限制 touch 区域的问题,一般可以通过设置 frame 或者利用 hitTest 处理,在 Swift 中也是一样的。在 SDiffuseMenu 中,对于点击范围的处理如下:</p> <pre> <code class="language-objectivec">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跟着调整了</code></pre> <p>下图是 Playground 中 ScaleRect方法小测试,看着是不是很好用啊</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/f037712b728e0a41c3578aebf00d39b0.png"></p> <p>喜欢的朋友还请给个star哦,后续我会持续优化的!</p> <p> </p>