内容简介:更新说明:此教程由 Lea Marolt Sonnecnschein 升级至 iOS 12、Xcode 10 和 Swift 4.2。原文作者是 Colin Eberhardt。UI 控件是 app 最重要的组成部分。它们是让用户查看 app 并与之交互的图形化组件。苹果提供了一系列控件,比如 UITextField,UIButton 和 UISwitch。通过这些内置控件,你可以创建出各种各样的用户界面。但是,有时候我们需要做一些定制化,内置控件不一定能够满足我们的需要。
更新说明:此教程由 Lea Marolt Sonnecnschein 升级至 iOS 12、Xcode 10 和 Swift 4.2。原文作者是 Colin Eberhardt。
UI 控件是 app 最重要的组成部分。它们是让用户查看 app 并与之交互的图形化组件。苹果提供了一系列控件,比如 UITextField,UIButton 和 UISwitch。通过这些内置控件,你可以创建出各种各样的用户界面。
但是,有时候我们需要做一些定制化,内置控件不一定能够满足我们的需要。
iOS 自定义控件在你自己创建的控件。自定义控件也和标准控件一样,应该具有通用性和广泛性。你会发现有一个充满活力的开发者社区,他们喜欢分享自己的iOS自定义控件创作,这些控件都同时具备这两种特性。
在本教程中,你将编写一个 RangSlide 自定义控件。它是一个具有两个滑块的 slider,允许你同时设定下限值和上限值。你将学习如何扩展已有控件、设计和实现控件的 API,以及如何将你的新控件分享到开发者社区。
让我们开始定制它!
使用 Download Materials 按钮下载开始项目。
假设你要开发一个搜索在售房产的 app。这个 app 允许用户指定一个价格区间来过滤搜索结果。
你可以向用户展示两个 UISlider 控件,一个指定最大价格,一个指定最小价格。但是,这种 UI 不利于让用户直观地看出这是一个价格区间。如果在一个 slider 中显示两个滑块,一个用于表示最高价格一个用于表示最低价格会更好。
好的设计 vs 坏的设计
你可以继承 UIView,创建一个自定义 view,用于展现价格区间。这对于你的 app 是一种不错的选择 —— 但将它迁移到其它 app 时会比较麻烦。
更好的做法是尽量以通用的方式创建一个新组件,以便复用。对于自定义控件来说这非常重要。
创建 iOS 定制控件时,首先面对的第一个问题就是,要通过继承/扩展哪一个类来实现新控件。
你的 class 应该是 UIView 子类,只有这样才能在 app UI 中使用它。
如果你查看苹果的 UIKit 手册,你会看到框架中有许多控件,比如 UILable、UIWebView 都直接继承于 UIView。但是,UIButton 和 UISwitch 则是继承于 UIControl,它们的关系如下图所示:
注:UI 组件的完整类图,请参考 UIKit 框架参考 。
在本教程中,你将继承 UIControl。
在 Xcode 中打开开始项目。你的 slider 控件代码位于 RangeSlider.swift。在编写代码之前,请将它添加到 view controller 中,以便你能直观地看到它的变化。
打开 ViewController.swift 将内容修改为:
import UIKit class ViewController: UIViewController { let rangeSlider = RangeSlider(frame: .zero) override func viewDidLoad() { super.viewDidLoad() rangeSlider.backgroundColor = .red view.addSubview(rangeSlider) } override func viewDidLayoutSubviews() { let margin: CGFloat = 20 let width = view.bounds.width - 2 * margin let height: CGFloat = 30 rangeSlider.frame = CGRect(x: 0, y: 0, width: width, height: height) rangeSlider.center = view.center } }
这里我们创建了一个自定义控件,指定它的 frame,将它添加到 view 中。还设置了红色的背景色,以便它能在屏幕上显示出来。
Build & run 。你会看到:
在向控件中添加可视化元素之前,你需要定义几个属性用于保持控件的状态。这就开始构成了控件 API 的一部分。
注:控件的 API 定义了要暴露给控件使用者的方法和属性。
设置控件的默认属性
打开 RangeSlider.swift 将代码修改为:
import UIKit class RangeSlider: UIControl { var minimumValue: CGFloat = 0 var maximumValue: CGFloat = 1 var lowerValue: CGFloat = 0.2 var upperValue: CGFloat = 0.8 }
这 4 个属性描述了控件的所有状态。它们分别指定了某个区间的最大值、最小值,以及用户设置的上限值和下限值。
设计良好的控件需要指定默认属性,否则控件在屏幕上的显示将不正常。
接下来是控件上的互动元素:即负责表示区间的上限值和下限值的两个滑块,以及滑块所属的轨道。
CoreGraphics vs 位图
在屏幕上显示控件有两种主要方式:
- CoreGraphics: 用 CALayer 和 CoreGraphics 来显示控件。
- 位图:以图片的方式表示控件的各个元素。
每种方式都各有长短,大概罗列如下:
-
Core Graphics: 用 Core Graphics 编写控件意味着你必须自己编写图形绘制代码,这需要你做更多的工作。但是,这种技术允许你创建更加灵活的 API。
用 Core Graphics,你可以对控件的每个属性进行参数化,比如颜色、边框线宽度、弧线 —— 甚至包括绘制控件时的每一个可视化元素。
-
位图:用位图编写控件是自定义控件中最简单的方式。如果想让其它开发者能够修改控件的外观,你只需要将这些图片暴露成 UIImage 属性即可。
使用图片对使用控件的开发者来说是是最灵活的方式。开发者可以修改控件外观的每个像素和细节,但需要熟练的图形设计技巧——但要用代码来修改控件则比较难。
在本教程中,你将两种都尝试一下。我们会用图片来渲染滑块,而用 core graphics 绘制滑轨。
注:有趣的是,苹果在自己的控件中更喜欢用位图。最大的原因是它们知道每个控件的大小,同时不想让你进行过多的定制化。总之,他们想让所有的 app 都拥有类似的外观。
添加滑块
打开 RangeSlider.swift 添加下列属性,就在你之前定义的属性下面:
var thumbImage = #imageLiteral(resourceName: "Oval") private let trackLayer = CALayer() private let lowerThumbImageView = UIImageView() private let upperThumbImageView = UIImageView()
trackLayer、lowerThumbImageView 和 upperThumbImageView 用于绘制控件的各个部分。
仍然在 RangeSlider, 添加构造函数:
override init(frame: CGRect) { super.init(frame: frame) trackLayer.backgroundColor = UIColor.blue.cgColor layer.addSublayer(trackLayer) lowerThumbImageView.image = thumbImage addSubview(lowerThumbImageView) upperThumbImageView.image = thumbImage addSubview(upperThumbImageView) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") }
改构造函数将 layer 和 view 添加到控件中。
要看见所添加的元素,你必须指定它们的 frame。在构造函数后面加入:
// 1 private func updateLayerFrames() { trackLayer.frame = bounds.insetBy(dx: 0.0, dy: bounds.height / 3) trackLayer.setNeedsDisplay() lowerThumbImageView.frame = CGRect(origin: thumbOriginForValue(lowerValue), size: thumbImage.size) upperThumbImageView.frame = CGRect(origin: thumbOriginForValue(upperValue), size: thumbImage.size) } // 2 func positionForValue(_ value: CGFloat) -> CGFloat { return bounds.width * value } // 3 private func thumbOriginForValue(_ value: CGFloat) -> CGPoint { let x = positionForValue(value) - thumbImage.size.width / 2.0 return CGPoint(x: x, y: (bounds.height - thumbImage.size.height) / 2.0) }
在这些方法中分别进行了:
- 第一个方法,让 trackerLayer 居中对齐,通过 thumbOriginForValue 方法计算出滑块的位置。
- 这个方法中,根据给定的值计算和bound 计算出位置。
- 最后一个方法,thumbOriginForValue 返回一个位置,让滑块中心正好位于计算出的位置。
在 init(frame:) 方法中调用新方法:
updateLayerFrames()
然后,覆盖 frame 属性,实现属性观察器:
override var frame: CGRect { didSet { updateLayerFrames() } }
当 frame 改变时,属性观察器会重新计算 layer 的 frame。当控件通过非默认的 frame 来进行初始化时,这是必须的,比如像 ViewController.swift 中那样。
Build & run ,你的 slider 显示出来了!
红色是控件的背景色,蓝色是滑轨颜色,两个蓝色的圆分别两个滑块。
你的控件显示出来了,但它们还不能相应事件!
对于这个控件而言,用户应该能够通过拖动每个滑块来设置一个区间值。你应该响应这些事件,更新 UI 和外部属性。
响应触摸
打开 RangeSlider.swift 添加属性:
private var previousLocation = CGPoint()
这个属性用于跟踪触摸位置。
要怎样跟踪控件的各种触摸和释放事件?
UIControl 提供了几个跟踪触摸的方法。UIControl 的子类可以覆盖这些方法,添加自己的事件处理逻辑。
在你的自定义控件中,覆盖 3 个 UIControl 方法:beginTracking(_:with:)、continueTracking(_:with:) 和 endTracking(_:with:)。
在 RangeSlider.swift 文件最后添加下列代码:
extension RangeSlider { override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { // 1 previousLocation = touch.location(in: self) // 2 if lowerThumbImageView.frame.contains(previousLocation) { lowerThumbImageView.isHighlighted = true } else if upperThumbImageView.frame.contains(previousLocation) { upperThumbImageView.isHighlighted = true } // 3 return lowerThumbImageView.isHighlighted || upperThumbImageView.isHighlighted } }
当用户第一次触摸控件时,iOS 会调用这个方法。在这个方法中:
- 首先,将触摸事件转换到控件的坐标空间。
- 接下来,检查两个滑块是否被触摸。
- 通知 UIControl 父类后续触摸是否应该被跟踪。如果某个滑块被高亮的话,那么我们就继续跟踪触摸事件。
有了基本的触摸事件之后,你需要在用户手指滑过屏幕时处理这些事件。
在 beginTracking 方法之后添加:
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { let location = touch.location(in: self) // 1 let deltaLocation = location.x - previousLocation.x let deltaValue = (maximumValue - minimumValue) * deltaLocation / bounds.width previousLocation = location // 2 if lowerThumbImageView.isHighlighted { lowerValue += deltaValue lowerValue = boundValue(lowerValue, toLowerValue: minimumValue, upperValue: upperValue) } else if upperThumbImageView.isHighlighted { upperValue += deltaValue upperValue = boundValue(upperValue, toLowerValue: lowerValue, upperValue: maximumValue) } // 3 CATransaction.begin() CATransaction.setDisableActions(true) updateLayerFrames() CATransaction.commit() return true } // 4 private func boundValue(_ value: CGFloat, toLowerValue lowerValue: CGFloat, upperValue: CGFloat) -> CGFloat { return min(max(value, lowerValue), upperValue) }
代码解释如下:
- 首先,计算偏移坐标,即用户手指划过的像素点。然后根据控件的上限值和下限值将它乘以一个相对系数。
- 根据用户拖动的位置调整上限值或下限值。
- 设置 CATransaction 的 disabledActions 属性。这会使每个 layer 的 frame 改变立即被应用,而不是等待动画完成。最后,调用 updateLayerFrames,将滑块移动到正确的位置。
- boundValue(_:toLowerValue:upperValue:) 将传入的值限定在某个范围。这个助手方法比起使用 min/max 函数要简单易读。
现在你就实现了 slider 的拖动,但还需要处理触摸和拖拽事件的结束。
在 continueTracking(_:with:) 后添加:
override func endTracking(_ touch: UITouch?, with event: UIEvent?) { lowerThumbImageView.isHighlighted = false upperThumbImageView.isHighlighted = false }
这个方法重置了两个滑块的非高亮状态。
Build & run,测试一下你的新 slider!你现在可以拖动滑块了。
注意,当 slider 在跟踪触摸时,你可以将手指拖出控件的范围之外,然后又拖回控件,跟踪并不会失效。对于低分辨率小屏设备来说,这是一个相当有用的特性 —— 因为它们对于手指来说触摸的空间更小!
控件已经可以交互了,用户可以通过它设定上限值和下限值了。但如何将这种变化通知给 app 以便 app 知晓控件当前的变化呢?
变化通知的方式有许多种:NSNotification、键值观察(KVO),委托模式、目标-动作模式等等。选择太多了。
那么,该怎么做呢?
如果你观察 UIKit 的控件,你会发现它们没有用 NSNotification,也不主张使用 KVO,为了保持一致性,你可以排除这两种选择。在 UIKit 中大量的是使用另外两种 —— 委托模型和目标-动作模型。
我们来细说一下这两种方式:
** 委托模式 **: 定义一个协议,声明一系列通知要用到的方法。这种控件通常会有一个名为 delegate 的属性,它接收一个实现该协议的对象。例如 UITableView,它声明了 UITableViewDelegate 协议。注意,这类控件只能接收当个的 delegate 对象。一个委托方法可以接受任意数量的参数,因此无论你有多少信息都可以传递给委托方法。
** 目标-动作模式 **:UIControl 基类提供了目标-动作模式。当控件状态发生改变,事件会被通知给目标对象,这个事件是一个 UIControlEvents 的枚举值。你可以为控件的 action 提供多个目标对象,也可以创建自定义事件(参考 UIControlEventApplicationReserved),限制是最多可以有 4 个自定义事件。控件的动作不会发送任何数据,因此当事件触发时,不能通过动作来传递额外的数据。
两种方式的区别在于:
- 多播: 目标-动作模型的变化通知是多播的,而委托模型只能通知当个委托对象。
- 灵活性:如果用委托模型自定义协议,你可以控制要传递的信息的多少。目标-动作模型无法传递额外的信息,客户端接收事件时必须自己获取这些信息。
你的 range slider 控件并没有太多需要通知的状态变化和交互。唯一的变化就是控件的上限值和下限值。
这样,用目标-动作模式就好了。这也是本教程一开始就继承 UIControl 的原因之一。
好,现在都明白了吗?:]
slider 的值在 continueTracking 方法修改,因此在这里添加通知代码。
打开 RangeSlider.swift,找到 continueTracking(_:with:) 在 return 之前添加:
sendActions(for: .valueChanged)
这就是当值变化发生时,通知所有订阅者的代码了。
有了通知代码之后,你需要把它用到 app 中。
打开 ViewController.swift 在类底部添加方法:
@objc func rangeSliderValueChanged(_ rangeSlider: RangeSlider) { let values = "(\(rangeSlider.lowerValue) \(rangeSlider.upperValue))" print("Range slider value changed: \(values)") }
这个方法向控制台输出了 slider 的值,以此证明控件已经发送了通知。
现在,在 viewDidLoad() 中添加代码:
rangeSlider.addTarget(self, action: #selector(rangeSliderValueChanged(_:)), for: .valueChanged)
每当 slider 发送 valueChanged 事件时就调用 rangeSliderValueChanged(_:smiley: 方法。
Build & run,左右拖动滑块。你会看到控制台中输出了控件的值:
Range slider value changed: (0.117670682730924 0.390361445783134) Range slider value changed: (0.117670682730924 0.38835341365462) Range slider value changed: (0.117670682730924 0.382329317269078)
你可能对 slider 的彩色 UI 不太感冒。它看起来就像是水果沙拉!让我们来给它动个小小的整容手术。
用 Core Graphics 修改控件
首先,来修改滑轨的图片。
在 RangeSliderTrackLayer.swift 中,将代码修改为:
import UIKit class RangeSliderTrackLayer: CALayer { weak var rangeSlider: RangeSlider? }
这段代码添加了一个对 RangeSlider 的引用。因为 slider 中包含了滑轨,为了避免循环持有,我们使用了弱引用。
打开 RangeSlider.swift,找到 trackLayer 属性,将它修改为 RangeSliderTrackLayer 类型:
private let trackLayer = RangeSliderTrackLayer()
然后,将 init(frame:)方法修改为:
override init(frame: CGRect) { super.init(frame: frame) trackLayer.rangeSlider = self trackLayer.contentsScale = UIScreen.main.scale layer.addSublayer(trackLayer) lowerThumbImageView.image = thumbImage addSubview(lowerThumbImageView) upperThumbImageView.image = thumbImage addSubview(upperThumbImageView) }
这里确保 trackLayer 能引用 range slider,并移除默认的背景色。设置 contentsScale 属性,让它等于设备屏幕的 scale,以更好地适配视网膜屏。
另外还需要移除控件的红色背景。
打开 ViewController.swift,在 viweDidLoad 方法中找到这一句并删除它:
rangeSlider.backgroundColor = .red
Build & run。你会看到:
滑块飘起来了?就是这样!
别急 —— 你已经删掉了花哨的颜色。你的控件并没有消失,但现在背景变成了空白,你需要稍微修饰它一下。
因为大部分开发者喜欢在编写 app 时定制控件让它和所编写的 app 保持一致的外观,因此你需要为 slider 增加一些属性,以便允许对控件外观进行一定的定制。
打开 RangeSlider.swift 添加下列属性:
var trackTintColor = UIColor(white: 0.9, alpha: 1) var trackHighlightTintColor = UIColor(red: 0, green: 0.45, blue: 0.94, alpha: 1)
然后打开 RangeSliderTrackLayer.swift。
这个 layer 负责渲染滑轨。它继承了 CALayer,现在它只会绘制固定的颜色。
要绘制滑轨,你必须实现 draw(in:) 方法,用 Core Graphics API 进行绘制。
注:关于更多 Core Graphics 的内容,建议阅读本站的 Core Graphics 101 教程系列 ,因为 Core Graphics 已经超出了本教程的范畴。
在 RangeSliderTrackLayer 中添加方法:
override func draw(in ctx: CGContext) { guard let slider = rangeSlider else { return } let path = UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius) ctx.addPath(path.cgPath) ctx.setFillColor(slider.trackTintColor.cgColor) ctx.fillPath() ctx.setFillColor(slider.trackHighlightTintColor.cgColor) let lowerValuePosition = slider.positionForValue(slider.lowerValue) let upperValuePosition = slider.positionForValue(slider.upperValue) let rect = CGRect(x: lowerValuePosition, y: 0, width: upperValuePosition - lowerValuePosition, height: bounds.height) ctx.fill(rect) }
当滑轨被 clip 时,你填充背景色。然后,再填充高亮颜色。
Build & run,你会看到新的滑轨显示如下:
控件属性改变时的处理
现在控件看起来很漂亮,它的可视化样式是可变化的,它还支持目标-动作模式。
看起来已经完成了?
试想当 slider 渲染之后,属性被代码修改后会发生什么?例如,你可能想修改 slider 的预设值,或者修改滑轨的高亮色,以标出 slider 的有效范围。
现在,属性的 setter 方法还没有观察器。你必须在控件中添加它们。你需要实现当控件 frame 改变是会绘制时的属性观察器。
打开 RangeSlider.swift 将属性定义修改为:
var minimumValue: CGFloat = 0 { didSet { updateLayerFrames() } } var maximumValue: CGFloat = 1 { didSet { updateLayerFrames() } } var lowerValue: CGFloat = 0.2 { didSet { updateLayerFrames() } } var upperValue: CGFloat = 0.8 { didSet { updateLayerFrames() } } var trackTintColor = UIColor(white: 0.9, alpha: 1) { didSet { trackLayer.setNeedsDisplay() } } var trackHighlightTintColor = UIColor(red: 0, green: 0.45, blue: 0.94, alpha: 1) { didSet { trackLayer.setNeedsDisplay() } } var thumbImage = #imageLiteral(resourceName: "Oval") { didSet { upperThumbImageView.image = thumbImage lowerThumbImageView.image = thumbImage updateLayerFrames() } } var highlightedThumbImage = #imageLiteral(resourceName: "HighlightedOval") { didSet { upperThumbImageView.highlightedImage = highlightedThumbImage lowerThumbImageView.highlightedImage = highlightedThumbImage updateLayerFrames() } }
对于 trackLayer 你调用的是 setNeedsDisplay 方法,而其它属性则调用 updateLayerFrames() 方法。当你修改 thumbImage 或者 hightlightedThumbImage 时,你还需要同时改变它们的 Image View 的对应属性。
你还添加了一个新属性。hightlitedThumImage 当滑块被高亮时显示。它有助于用户对正在交互的控件有更直观的感受。
然后,找到 updateLayerFrames() 在方法的开始添加:
CATransaction.begin() CATransaction.setDisableActions(true)
在这个方法的最后添加:
CATransaction.commit()
这些代码将 frame 的 update 方法包裹在一个事务中,让重新绘制更加平滑。它还禁用了 Layer 的隐式动画,这和你之前的做法是一样的,因此 layer 的 frame 会立即刷新。
因为现在每当上限值和下限值被改变时都会自动刷新 frame,所以就可以删除掉 continueTracking(_:with:) 中的下列代码了:
// 3 CATransaction.begin() CATransaction.setDisableActions(true) updateLayerFrames() CATransaction.commit()
关于 range slider 属性变化的处理就这些了。
但是,你还需要用代码来测试一下属性观察器是否正常。
打开 ViewController.swift 在 viewDidLoad() 最后添加:
let time = DispatchTime.now() + 1 DispatchQueue.main.asyncAfter(deadline: time) { self.rangeSlider.trackHighlightTintColor = .red self.rangeSlider.thumbImage = #imageLiteral(resourceName: "RectThumb") self.rangeSlider.highlightedThumbImage = #imageLiteral(resourceName: "HighlightedRect") }
这段代码在 1 秒中后修改控件的某些属性。还会修改滑轨的高亮颜色未红色,滑块的图片为矩形。
Build & run。一秒中后,你会看到 slider 一开始是这样的:
变成了这样的:
酷吧?!
接下来去哪里?
你已经完成了 range slider,可以用到项目中去了!点击下面的 Download Materials 链接可以下载最终版的项目。
但是,编写通用控件的最大好处是可以在其它项目中——以及和其它开发者分享。
你的控件已经可以发布了吗?
并没有。在分享你控件之前,请考虑以下几点:
文档—— 每个开发者最需要的东西!虽然你觉得自己的代码非常漂亮,实现了自文档化,但其它开发者可不这样认为。好的做法是提供一个公开的 API 文档,至少要对所有公开分享的代码编写文档。也就是对所有公有类和属性编写文档。
例如,你需要在文档中解释 RangeSlider 是什么 —— 它是一个 slider,有 4 个属性:最小值、最大值、上限值、下限值 —— 以及它是干什么的 —— 允许用户以直观的方式指定一个数值范围。
健壮性—— 如果你将上限值设置为比最大值还大的数会怎样?你自己当然不会这样做,但你能保证其他人不会这样干吗?你必须确保控件状态始终是有效的——无论愚蠢的 程序员 试图对它做什么。
API 的设计—— 上一点涉及到了一个更广泛的话题 —— API 的设计。创建灵活的、符合需求的、健壮的 API 能使你的控件被广泛应用(变得十分流行)。
API 设计是一个非常深奥的主题,甚至超出了本教程的范围。如果你有兴趣,请看 Matt Gemmell 的 API 设计的 25 条原则 。
能够让你的控件分享到全世界的地方有许多。这里有几点建议:
- GitHub – 分享开源项目的最常见的地方。在 GitHub 上有数不清的 iOS 自定义控件。它使得人们很容易获取你的代码,通过 fork 的方式进行合作,或者提出问题。
- CocoaPods – 允许人们很容易将你的控件添加到他们的项目中,你可以通过 CocoaPods 分享你的控件,它是一个 iOS 和 macOS 项目的依赖管理工具。
- Cocoa Controls – 这个网站提供了一个商业控件和开源控件的目录。上面的许多开源控件都是放在 github 上的,它也是一个激发创意的地方。
希望你享受创建这个控件的过程,也许它能激发你创建出自己的自定义控件。如果是这样,请在本贴的评论中分享它 —— 我们期望看到你的作品!
以上所述就是小编给大家介绍的《[译]自定义控件教程: 可复用的 Slider》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。