版本记录
版本号 | 时间 |
---|---|
V1.0 | 2018.11.19 星期一 |
前言
iOS中有关视图控件用户能看到的都在UIKit框架里面,用户交互也是通过UIKit进行的。感兴趣的参考上面几篇文章。
1. UIKit框架(一) —— UIKit动力学和移动效果(一)
2. UIKit框架(二) —— UIKit动力学和移动效果(二)
3. UIKit框架(三) —— UICollectionViewCell的扩张效果的实现(一)
4. UIKit框架(四) —— UICollectionViewCell的扩张效果的实现(二)
开始
首先看一下写作环境
Swift 4.2, iOS 12, Xcode 10
用户界面控件是任何应用程序中最重要的构建块之一。它们充当图形组件,允许用户查看应用程序并与之交互。 Apple提供了一组控件,例如UITextField
,UIButton
和UISwitch
。有了这个预先存在的控件工具箱,您可以创建各种各样的用户界面。
iOS自定义控件只不过是您自己创建的控件。自定义控件,如标准控件,应该是通用的。你会发现有一个活跃且充满活力的开发者社区,他们喜欢分享他们的iOS自定义控件创作,前提是他们都是这些东西。
在本教程中,您将实现RangeSlider
iOS自定义控件。此控件类似于双端滑块,可让您选择最小值和最大值。您将涉及扩展现有控件,设计和实现控件API以及如何与开发社区共享新控件等概念。
是时候开始定制了!
假设您正在开发一个搜索待售物业列表的应用程序。 这个虚构的应用程序允许用户过滤搜索结果,使其落在特定的价格范围内。
您可以提供一个界面,向用户显示一对UISlider
控件,一个用于设置最高价格,另一个用于设置最低价格。 但是,此界面无法帮助用户查看价格范围。 使用两个指示器呈现单个滑块以指示其搜索条件的高价和低价范围会更好。
您可以通过继承UIView
并创建一个定制视图来可视化价格范围来构建此范围滑块。 对于您的应用程序的上下文来说这没什么问题 - 但将它移植到其他应用程序会很困难。
将这个新组件尽可能地设为通用是一个更好的主意,以便您以后可以重用它。 这是自定义控件的本质。
创建iOS自定义控件时需要做出的第一个决定是将现有类子类化或扩展以制作新控件。
您的类必须是UIView
子类,才能在应用程序的UI中使用它。
如果你检查Apple的UIKit引用,你会看到许多框架控件,比如UILabel
和UIWebView
,直接子类UIView
。 但是,有一些,如UIButton
和UISwitch
,它们是UIControl
的子类,如下面的层次结构所示:
注意:有关UI组件的完整类层次结构,请查看 UIKit Framework Reference。
在本教程中,您将继承UIControl
的子类。
在Xcode中打开启动项目。 您将滑块控制代码放在RangeSlider.swift
中。 但是,在为控件编写任何代码之前,请将其添加到视图控制器中,以便您可以观察其演变。
打开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
}
}
此代码在给定框架中创建控件的实例,并将其添加到视图中。 它还将背景颜色设置为红色,以便控件在屏幕上可见。
构建并运行您的应用程序。 您应该看到类似于以下内容的内容:
在将可视元素添加到控件之前,您需要一些属性来跟踪控件的状态。 这将构成控件的应用程序编程接口(简称API)的开始。
注意:您的控件的API定义了您决定向将使用您的控件的其他开发人员公开的方法和属性。
Adding Default Control Properties - 添加默认控件属性
打开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
}
您只需要这四个属性来描述此控件的状态。 您可以提供范围的最大值和最小值,以及用户设置的上限和下限值。
精心设计的控件应该定义一些默认的属性值,否则当它在屏幕上绘制时你的控件看起来很奇怪。
现在是时候处理控件的交互元素了:即用指示器表示高值和低值以及指示器将滑动的轨道。
CoreGraphics vs. Images
您可以在屏幕上呈现控件的两种主要方法:
- 1)
CoreGraphics
:使用图层和CoreGraphics
的组合渲染您的控件。 - 2)
Images
:创建代表控件各种元素的图像。
每种技术都有利弊,如下所述:
-
Core Graphics:使用
Core Graphics
构建控件意味着您必须自己编写渲染代码,这需要更多的努力。但是,此技术允许您创建更灵活的API。
使用Core Graphics
,您可以参数化控件的每个功能,例如颜色,边框粗细和曲率 - 几乎所有用于绘制控件的视觉元素!
-
Images:使用图像构建控件可能是创建控件的最简单选项。如果您希望其他开发人员能够更改控件的外观,则需要将这些图像公开为
UIImage
属性。
使用images
为使用您的控件的开发人员提供了最大的灵活性。开发人员可以更改控件外观的每个像素和每个细节,但这需要出色的图形设计技能 - 并且很难从代码中修改控件。
在本教程中,您将使用两者。您将使用images
渲染指示器和CoreGraphics
来渲染轨道图层。
注意:有趣的是,Apple也倾向于选择在其控件中使用
images
。这很可能是因为他们知道每个控件的大小,并且不希望允许过多的自定义。毕竟,他们希望所有应用程序最终都具有类似的外观。
Adding thumbs to your custom control - 将指示器添加到自定义控件
打开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")
}
此初始化程序将图层和视图添加到控件。
要查看添加的元素,您需要设置其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)
}
以下是这些方法的内容:
- 1) 在第一种方法中,您将
trackLayer
居中并使用thumbOriginForValue(_ :)
计算指示器的位置。 - 2) 此方法将给定值缩放到边界的上下文。
- 3) 最后,
thumbOriginForValue(_ :)
返回位置,以便指示器在给定缩放值的情况下居中。
将以下代码添加到init(frame :)
的末尾以调用更新方法:
updateLayerFrames()
接下来,通过将以下内容添加到类的顶部来重写frame
并实现属性观察器:
override var frame: CGRect {
didSet {
updateLayerFrames()
}
}
属性观察者在帧更改时更新图层帧。 当使用不像ViewController.swift
中的最终frame那样初始化控件时,这是必需的。
构建并运行您的应用程序。 你的滑块开始成型!
红色是整个控件的背景色;蓝色是滑块的轨道颜色;蓝色圆圈是上下两个值的两个指示器。
您的控件开始在视觉上形成,但您无法与它进行交互!
为了您的控制,用户必须能够拖动每个指示器以设置所需的控件范围。 您将处理这些交互并更新UI和控件公开的属性。
Adding Touch Handlers - 添加Touch处理
打开RangeSlider.swift
并添加以下属性以及其他属性:
private var previousLocation = CGPoint()
您可以使用此属性来跟踪触摸位置。
您如何跟踪控件的各种触摸和释放事件?
UIControl
提供了几种跟踪触摸的方法。 UIControl的子类可以重写这些方法以添加自己的交互逻辑。
在你的自定义控件中,你将重写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会调用此方法。 以下是它的工作原理:
- 1) 首先,它将触摸事件转换为控件的坐标空间。
- 2) 接下来,它会检查每个指示器视图以查看触摸是否在其frame内。
- 3) 返回值通知
UIControl
超类是否应跟踪后续触摸。 如果突出显示任一指示器,则继续跟踪触摸事件
现在您已经有了初始触摸事件,当用户的手指在屏幕上移动时,您需要处理事件。
在beginTracking(_:with :)
之后添加以下方法:
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)
}
这是代码细分:
- 1) 首先,计算增量位置,该位置确定用户手指行进的点数。 然后,您可以根据控件的最小值和最大值将其转换为缩放的
delta
值。 - 2) 在这里,您可以根据用户拖动滑块的位置调整上限或下限值。
- 3) 此部分在
CATransaction
中设置disabledActions
标志。 这可确保立即应用每个图层的frame更改,而没有动画。 最后,调用updateLayerFrames
将指示器移动到正确的位置。 - 4)
boundValue(_:toLowerValue:upperValue :)
限制传入的值,使其在指定范围内。 使用此辅助函数比嵌套的min/max
调用更容易阅读。
您已经编码了滑块的拖动,但您仍需要处理触摸和拖动事件的结束。
在continueTracking(_:with :)
之后添加以下方法:
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
lowerThumbImageView.isHighlighted = false
upperThumbImageView.isHighlighted = false
}
此代码将两个指示器重置为非突出显示状态。
构建并运行您的项目,并使用闪亮的新滑块! 你应该可以拖动指示器。
您会注意到,当滑块跟踪触摸时,您可以将手指拖动到控件的边界之外,然后返回控件内,而不会丢失跟踪操作。 对于具有低精度指示设备的小屏幕设备而言,这是一个重要的可用性功能 - 或者因为它们更常见。
Notifying Changes - 通知改变
您现在拥有一个交互式控件,用户可以操作它来设置上限和下限。但是,您如何将这些更改通知传达给调用应用程序,以便应用程序知道控件具有新值?
您可以实现许多不同的模式来提供更改通知:NSNotification
,键值观察(KVO),代理模式,target-action
模式以及许多其他模式。这么多选择!
那么该怎么办?
如果您查看UIKit控件,您会发现他们不使用NSNotification
或鼓励使用KVO
,因此为了与UIKit保持一致,您可以排除这两个选项。另外两种模式 - 代理模式,target-action
模式 - 在UIKit中广泛使用。
以下是对代理和target-action
模式的详细分析:
-
Delegate pattern - 委托模式:使用委托模式,您提供的协议包含用于一系列通知的方法。该控件有一个属性,通常名为
delegate
,它接受任何实现此协议的类。一个典型的例子是UITableView
,它提供了UITableViewDelegate
协议。请注意,这些控件只接受单个委托实例。委托方法可以使用任意数量的参数,因此您可以根据需要向这些方法传递尽可能多的信息。 -
Target-action pattern - 目标操作模式:UIControl基类提供目标操作模式。当发生控制状态的改变时,目标被通知由一个
UIControlEvents
枚举值描述的事件。您可以提供多个目标来控制操作,虽然可以创建自定义事件(请参阅UIControlEventApplicationReserved
),但您最多只能有四个自定义事件。控制操作无法通过事件发送任何信息,因此在触发事件时,它们不能用于传递额外信息。
这两种模式的主要区别如下:
-
Multicast:目标操作模式
multicasts
其更改通知,而委托模式绑定到单个委托实例。 - Flexibility:您可以在委托模式中自己定义协议,这意味着您可以精确控制传递的信息量。目标操作无法传递额外信息,客户端在收到事件后必须自行查找。
您的范围滑块控件没有为您提供通知所需的大量状态更改或交互。唯一改变的是控件的上限和下限值。
在这种情况下,target-action
模式非常有意义。这是您在iOS自定义控件教程开始时将UIControl
子类化的原因之一。
它现在有意义!
滑块值在continueTracking(_:with :)
内更新,因此您需要添加通知代码。
打开RangeSlider.swift
,找到continueTracking(_:with :)
并在return true
语句之前添加以下内容:
sendActions(for: .valueChanged)
这就是通知任何订阅目标的变化所需要做的全部工作。
现在您已经完成了通知处理,您应该将其连接到您的应用程序。
打开ViewController.swift
并将以下方法添加到类的底部:
@objc func rangeSliderValueChanged(_ rangeSlider: RangeSlider) {
let values = "(\(rangeSlider.lowerValue) \(rangeSlider.upperValue))"
print("Range slider value changed: \(values)")
}
此方法将范围滑块值记录到控制台,以证明控件正在按计划发送通知。
现在,将以下代码添加到viewDidLoad()
的末尾:
rangeSlider.addTarget(self, action: #selector(rangeSliderValueChanged(_:)),
for: .valueChanged)
每次范围滑块发送valueChanged
事件时,都会调用rangeSliderValueChanged(_ :)
。
构建并运行您的应用程序,并来回移动滑块。 您将在控制台中看到控件的值,类似于:
Range slider value changed: (0.117670682730924 0.390361445783134)
Range slider value changed: (0.117670682730924 0.38835341365462)
Range slider value changed: (0.117670682730924 0.382329317269078)
您现在可能已经厌倦了查看多色范围滑块UI。 它看起来像一个愤怒的水果沙拉! 是时候给控件一个急需的换装。
Modifying Your Control With Core Graphics - 使用核心图形修改您的控件
首先,您将更新滑块指示器移动的轨道图形。
在RangeSliderTrackLayer.swift
中,使用以下代码替换代码:
import UIKit
class RangeSliderTrackLayer: CALayer {
weak var rangeSlider: RangeSlider?
}
此代码将引用添加回RangeSlider
。 由于滑块拥有轨道,因此引用是一个weak变量,以避免循环引用。
打开RangeSlider.swift
,找到trackLayer
属性并将其修改为新图层类的实例:
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)
}
上面的代码确保新的轨道图层具有对范围滑块的引用,并删除默认的背景颜色。 设置contentsScale
因子以匹配设备屏幕的因子可确保视网膜显示屏上的一切都清晰。
还有一点:删除控件的红色背景。
打开ViewController.swift
,在viewDidLoad()
中找到以下行并将其删除:
rangeSlider.backgroundColor = .red
立即构建并运行。 你看到了什么?
指示器处于悬浮状态?
不要担心 - 你刚刚删除了华而不实的测试颜色。 你的控件仍在那里,但现在你有一个空白的画布来装扮它。
由于大多数开发人员喜欢它时控件可以配置为模拟他们正在编码的特定应用程序的外观和感觉,因此您将向滑块添加一些属性以允许自定义控件的外观。
打开RangeSlider.swift
并在upperValue
属性下添加以下属性:
var trackTintColor = UIColor(white: 0.9, alpha: 1)
var trackHighlightTintColor = UIColor(red: 0, green: 0.45, blue: 0.94, alpha: 1)
接下来,打开RangeSliderTrackLayer.swift
。
此图层渲染两个指示器滑动的轨道。 它目前继承自CALayer
,它只呈现纯色。
要绘制轨道,您需要实现draw(in :)
并使用Core Graphics API
来执行渲染。
将以下方法添加到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)
}
剪裁轨道形状后,您将填充背景。 之后,您将填写突出显示的范围。
构建并运行以查看您的新轨道图层! 它看起来像这样:
Handling Changes to Control Properties - 处理对控件属性的更改
控件现在看起来很时髦;视觉样式是多功能的,它支持target-action
通知。
听起来你已经完成了?
想一想如果在渲染后在代码中设置了一个范围滑块属性会发生什么。 例如,您可能希望将滑块范围更改为某个预设值,或更改轨道突出显示以指示有效范围。
目前,没有什么观察属性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()
}
}
您可以调用setNeedsDisplay()
来更改轨道图层,并为每个其他更改调用updateLayerFrames()
。 更改thumbImage
或highlightedThumbImage
时,还会更改其各自图像视图的属性。
您还添加了一个新属性!highlightThumbImage
显示指示器图像视图高亮显示。 它有助于为用户提供有关他们如何与控件交互的更多反馈。
现在,找到updateLayerFrames()
并将以下内容添加到方法的顶部:
CATransaction.begin()
CATransaction.setDisableActions(true)
将以下内容添加到方法的最底部:
CATransaction.commit()
此代码将整个帧更新包装到一个事务中,以使重新流渲染变得平滑。 它还会禁用图层上的隐式动画,就像之前一样,因此图层frame
会立即更新。
由于您现在每次更改上限值和下限值时自动更新frame
,请在continueTracking(_:with :)
中找到以下代码并将其删除:
// 3
CATransaction.begin()
CATransaction.setDisableActions(true)
updateLayerFrames()
CATransaction.commit()
这就是让范围滑块对属性更改做出反应所需要做的全部工作。
但是,您现在需要更多代码来测试新的属性观察器,并确保所有内容都已连接并按预期工作。
打开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")
}
这会在一秒延迟后更新某些控件的属性。 它还将轨道高亮颜色更改为红色,将指示器图像更改为矩形。
构建并运行您的项目。 一秒钟之后,您将看到范围滑块从此更改:
更改为:
已经很好了!
您的范围滑块现在功能齐全,可以在您自己的应用程序中使用!
不仅如此。在共享自定义控件之前,请考虑以下几点:
有很多地方可以开始与全世界共享自定义控件。以下是一些开始的建议:
- GitHub - 分享开源项目最受欢迎的地方之一。 GitHub上已有许多iOS自定义控件。它允许人们通过分配代码进行其他控制或在现有控件上引发问题来轻松访问代码并进行协作。
-
CocoaPods - 为了让人们可以轻松地将控件添加到他们的项目中,您可以通过
CocoaPods
共享它,CocoaPods
是iOS和macOS项目的依赖管理器。 -
Cocoa Controls - 此站点提供商业和开源控件的目录。
Cocoa Controls
涵盖的许多开源控件都托管在GitHub上,这是推广创作的好方法。
后记
本篇主要讲述了自定义控件:可重复使用的滑块,感兴趣的给个赞或者关注~~~
网友评论