自定义控件:利用 3D Touch 确认 Button 操作

作者:Yari D'areglia,原文链接,原文日期:2017-01-03
译者:SketchK;校对:Cee;定稿:CMB

在我看来,3D Touch 是能够追踪用户按压屏幕力度、并且是 iOS 的触碰处理中最有意思且未被充分挖掘的一个能力特性。

通过这个教程,我们会创建一个自定义的按钮,并且要求用户通过 3D Touch 操作进行确认。如果用户的设备不支持 3D Touch,控件对用户的处理也会回退到备选方案。

  1. 当用户开始点击屏幕时,一个圆形的进度条就会跟踪用户按压屏幕的力度。用户按压屏幕的力度会影响圆形视图填充进度,按得越用力,圆就被填充得越多(稍后我会展示在不支持 3D Touch 的设备上模拟该行为)。

  2. 当圆形被填充满的时候,它会变成一个处于激活状态的按钮,圆形进度条里的标签内容会变成 “OK” 且颜色变成绿色,这暗示着当前操作可以被确认。此时用户可以通过向上滑动手指并在圆圈上松开手指的方式来确认此操作。

通常,我们会通过弹窗的方式来询问用户是否想进行一个删除操作。我很乐意做一些 UX 交互方面的尝试,而且我认为 3D Touch 这种新的交互方式可以很好的替代原有的 “标准” 交互流程。你真的应该在一个实体机上体验一下 3D Touch,马上你就会了解到交互的便利性。😀

代码撸起来

如果你还不知道自定义控件的工作原理,我强烈建议你阅读一下之前我写的一篇关于创建自定义控件的教程,下载配套的工程文件。这样你就能轻松 hold 住接下来的内容了。

UI 画起来

当用户与按钮控件进行交互的时候会绘制圆形控件和标签控件,实现这个需求的代码很简单,让我们一起看下:

private let circle = CAShapeLayer()
private let msgLabel = CATextLayer()
private let container = CALayer()
.
.
.
 
private func drawControl(){
     
    // Circle
    var transform = CGAffineTransform.identity
    circle.frame = CGRect(x: 0, y: 0, width: size.width, height: size.height)
    circle.path = CGPath(ellipseIn: CGRect(x: 0,y: 0,width: size.width, height: size.height),
                         transform: &transform)
     
    circle.strokeColor = UIColor.white.cgColor
    circle.fillColor = UIColor.clear.cgColor
    circle.lineWidth = 1
    circle.lineCap = kCALineCapRound
    circle.strokeEnd = 0 // initially set to 0
    circle.shadowColor = UIColor.white.cgColor
    circle.shadowRadius = 2.0
    circle.shadowOpacity = 1.0
    circle.shadowOffset = CGSize.zero
    circle.contentsScale = UIScreen.main.scale
 
    // Label
    msgLabel.font = UIFont.systemFont(ofSize: 3.0)
    msgLabel.fontSize = 12
    msgLabel.foregroundColor = UIColor.white.cgColor
    msgLabel.string = ""
    msgLabel.alignmentMode = "center"
    msgLabel.frame = CGRect(x: 0, y: (size.height / 2) - 8.0, width: size.width, height: 12)
    msgLabel.contentsScale = UIScreen.main.scale
     
    // Put it all together
    container.frame = CGRect(x: 0, y: 0, width: size.width, height: size.height)
    container.addSublayer(msgLabel)
    container.addSublayer(circle)
     
    layer.addSublayer(container)
}

圆形标签的 layer 在这段代码中初始化后,被添加到了容器的 layer 中。这段代码没有需要特别关注的地方,仅需要注意的是圆形的 strokeEnd 属性值是 0。

对于任意一个图形的 layer,可以使用这个属性来对其进行动画操作。简单地说,系统会在 strokeStartstrokeEnd 之间渲染图形 layer 的路径,而这两个属性的默认值是 0 和 1,所以利用这个值区间是可以玩出许多漂亮的动画效果。但对于当前这个控件,我们设置 strokeEnd 的值为 0 ,因为我们要使用用户按压屏幕的力度来做动画。

控件的状态

使用 ConfirmActionButtonState 枚举类型来定义当前控制器的 UI 和行为状态。

enum ConfirmActionButtonState {
    case idle
    case updating
    case selected
    case confirmed
}

当该控件上没有任何操作时,它的状态是 idle;当用户开始进行交互时,它的状态会变成 updating;当圆形控件被填充完毕的时候,它的状态会变成 selected;如果此时用户将手指移动到绿色的圆圈中,它的状态又会继续变成 confirmed

如果用户手指离开了屏幕,且控件的状态已经是 confirmed 的时候,我们会继续传递这个按键操作。因为这时按钮已处于确认状态;反之,按钮又会回到 idle 态。

处理用户的点击

我们重写了 beginTrackingcontinueTrackingendTracking 三个方法来响应用户的点击并为自定义控件提供所有的信息。

通过这些方法我们会跟踪三个元素:

  1. 触摸点位置(touch Location)。用于决定应该在哪里绘制容器视图的 layer 层(它包含了圆形视图和信息标签)。
  2. 按压屏幕的力度(touch force) 值。需要根据这个值来设置圆形控件的动画并且通过它来决定自定义控件的状态是否应该设置成 updatingselected 或者 confirmed
  3. 更新后的触摸点的位置(updated touch location)。我们必须跟踪用户的触摸点来验证它是否在容器视图 layer 层的 bounds 内,如果满足这个条件,我们就需要更新状态为 confirmed 或者 updating

首先看看 begingTracking 方法的代码。

  override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
      super.beginTracking(touch, with: event)
       
      if traitCollection.forceTouchCapability != UIForceTouchCapability.available{
// fallback code ….
      }
       
      let initialLocation = touch.location(in: self)
       
      CATransaction.begin()
      CATransaction.setDisableActions(true)
      container.position = initialLocation ++ CGPoint(x: 0, y: -size.height)
      CATransaction.commit()
       
      return true
  }

首先我们检查设备是否可以使用 3D Touch,如果不支持这个特性的话,我们会执行一个备选代码(在后面会具体讨论备选代码的事情)。然后通过触摸点的位置减去自定义控件高度的方式来计算容器视图 layer 的位置。 ++ 操作符的定义在文件的最下面,它的作用就是允许 CGPoint 类型的元素进行加法计算。

为了避免系统的隐式动画,需要在 setDisableActions 方法后设置容器视图的位置。(关于这个问题的详细信息可以参考:CALayer: CATransaction in Depth

continueTracking 这个函数中,我们执行所有必要的操作来确认控件的状态。

override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
    super.continueTracking(touch, with: event)
    lastTouchPosition = touch
    updateSelection(with:touch)
     
    return true
}

updateSelection 方法中的 touch 更新后,不支持 3D Touch 的设备会使用到 lastTouchPosition做一些处理,具体的内容后面会详细介绍。

updateSelection 的代码如下所示:

private func updateSelection(with touch: UITouch) {
     
    if self.traitCollection.forceTouchCapability == UIForceTouchCapability.available{
        intention = 1.0 * (min(touch.force, 3.0) / min(touch.maximumPossibleForce, 3.0))
    }
     
    if intention > 0.97 {
        if container.frame.contains(touch.location(in:self)){
            selectionState = .confirmed
        }else{
            selectionState = .selected
        }
        updateUI(with: 1.0)
    }
    else{
        if !container.frame.contains(touch.location(in:self)){
            selectionState = .updating
            updateUI(with: intention)
        }
    }
}

同样,首先要检查设备是否支持 3D Touch 特性,如果支持这个特性,我们会计算当前的“用户意向”,这个 intention 属性的值区间在 0(没有触摸事件被检测到)到 1(按压屏幕的力度达到了所需的最大值)之间。获取这个属性值的方法很简单:用当前压力除以最大压力的值作为 intention 的值即可。经过真机调试后,我发现如果使用这种方式实现的话,用户需要用很大的力量来按压屏幕才能达到最大值,出于节省力气的考虑,我对压力值做了一个 3.0 的上限。
(事实上,我不太确定使用 “intention” 作为命名是不是一个好的选择...使用英语做母语的朋友们,请让我知道这个命名是否明确的表达了这个属性的作用😝)

现在通过这个触摸循环可以计算出 intention 的具体值,从而就可以利用它来更新 UI 和控件的状态。如果 intention 的值大于 0.97 且用户的触摸点已经在绿色圆形区域内,这个控件的状态就会变为 confirmed,否则,即使用户一直按压删除按钮,控件的状态也只是停留在 selected。如果 intention 的值小于 0.97,控件的状态会处于 updating 的状态。

updateUI 方法会拿到当前的 intention 值,并把它赋值给圆形视图 layer 的 endStroke 属性。任何与 intention 相关的 UI 操作都可以放在这个方法中进行。

private func updateUI(with value:CGFloat){
    circle.strokeEnd = value
}

最后,我们重写了 endTracking 方法。当控件状态为 confirmed 的时候,该方法可以触发 valueChanged 事件。

override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
    super.endTracking(touch, with: event)
    intention = 0
     
    if selectionState == .confirmed{
        self.sendActions(for: UIControlEvents.valueChanged)
    }else{
        selectionState = .idle
        circle.strokeEnd = 0
    }
}

如果你仔细查看了 Main.storyboard 文件,你就会发现删除按钮的 valueChanged 动作已经与 ViewController 的 confirmDelete 方法相关联了。并且比较容易发现这个删除按钮的 class 属性已经设置成 ConfirmActionButton 了。

控件状态和 UI

这个控件的 UI 是与与其自身状态息息相关的。为了简化,我们将更新 UI 的代码直接放在了 selectionState 属性的 didSet 方法中。

这段代码很简单,它包含了根据状态来更新圆形视图颜色和标签文字内容的操作,以及对圆形视图调用 setNeedLayout 方法进行重绘的操作。

private var selectionState:ConfirmActionButtonState = .idle {
    didSet{
        switch self.selectionState {
        case .idle, .updating:
            if oldValue != .updating || oldValue != .idle {
                circle.strokeColor = UIColor.white.cgColor
                circle.shadowColor = UIColor.white.cgColor
                circle.transform = CATransform3DIdentity
                msgLabel.string = ""
            }
             
        case .selected:
            if oldValue != .selected{
                circle.strokeColor = UIColor.red.cgColor
                circle.shadowColor = UIColor.red.cgColor
                circle.transform = CATransform3DMakeScale(1.1, 1.1, 1)
                msgLabel.string = "CONFIRM"
            }
             
        case .confirmed:
            if oldValue != .confirmed{
                circle.strokeColor = UIColor.green.cgColor
                circle.shadowColor = UIColor.green.cgColor
                circle.transform = CATransform3DMakeScale(1.3, 1.3, 1)
                msgLabel.string = "OK"
            }
        }
        circle.setNeedsLayout()
    }
}

备选代码

我们快速浏览一下为不支持 3D Touch 特性的设备而提供的备选代码。由于我想在所有的设备上保持相同的设计效果,所以在不支持 3D Touch 特性的设备中,我让 intention 属性与时间关联在了一起,而不再是按压屏幕的力度值。其他方面的逻辑与我们之前所说的保持一致,但是当用户按压 delete 按钮时,intention 属性会以 0.1 秒的速度更新。下面就是在 beginTracking 中如何定义计时器的了:

if traitCollection.forceTouchCapability != UIForceTouchCapability.available{
    timer = Timer.scheduledTimer(timeInterval: 0.1,
                                 target: self,
                                 selector: #selector(ConfirmActionButton.updateTimedIntention),
                                 userInfo: nil,
                                 repeats: true)
    timer?.fire()
}

updateTimedIntention 方法能在两秒内将 intention 的值更新到最大值(1.0):

func updateTimedIntention(){
    intention += CGFloat(0.1 / 2.0)
    updateSelection(with: lastTouchPosition)
}

小结

我十分享受写这段代码的过程,而且在后面的日子里我还会讨论其他自定义控件。在我看来,利用设备的新特性来改进自定义 UI 和提升用户体验的工作还有很大的进步空间...我希望这个教程能对你有所启发😀。

下载源代码

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,542评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,596评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,021评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,682评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,792评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,985评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,107评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,845评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,299评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,612评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,747评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,441评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,072评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,828评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,069评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,545评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,658评论 2 350

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,846评论 25 707
  • 前言 关于这篇文章 由于iPhone 6S发布不到一年的时间,很多新特性、新技术还未普遍,不管是3D Touch的...
    Tangentw阅读 4,480评论 8 18
  • 今天小年 跟丁先生带丁语涵回了一趟奶奶家 本来还担心丁语涵耍驴不干 结果我把她放奶奶家 去了趟市场回来 发现她把袜...
    敬文加油阅读 808评论 6 6
  • 范蠡:“你可怕死?” 西施:“我不怕死,我只怕再也见不到你。” 在浣纱溪边,见到你的那一刻起,我就知道,我这一生将...
    蔣川阅读 392评论 0 0
  • 这两个控件的父视图的宽度或者高度为0,在Autolayout约束不完整的情况下容易出现这个问题。根据iOS 时间的...
    lanjing阅读 2,497评论 0 0