写安卓的时候,按钮是"最不用想"的控件。android:background="@drawable/bg_btn" 配个 ripple,setOnClickListener 挂上去,点下去手指底下就有一圈水波纹扩散开。这套东西内建在 Material 里,你什么都没做就已经有了。
iOS 这边照着安卓的肌肉记忆写:setTitle 设文字、addTarget 挂事件。事件本身没问题,问题出在视觉上。UIButton 默认没有按压态,手指按下去和没按下去长得一模一样 。在安卓上这事从来不用想:Material 的 ripple 是白送的,iOS 的按压反馈得自己给 highlighted 配一套样式。
先把状态这件事说清楚。UIButton 的外观是按"状态"配的,不是按"属性"配的。这是安卓 StateListDrawable 的思路(<item android:state_pressed="true">),但 iOS 把它做成了 UIControl.State 位掩码,可以组合:
| 状态 | 含义 | 安卓对应 |
|---|---|---|
.normal |
默认态 | 无 state 的默认 item |
.highlighted |
手指按下的瞬间 | state_pressed |
.selected |
选中态(需手动 isSelected = true) |
state_selected |
.disabled |
禁用态(isEnabled = false) |
state_enabled="false" |
.focused |
焦点态(遥控器/键盘导航) | state_focused |
关键点:给 .normal 设的样式不会自动沿用到其他状态 。setTitleColor(.white, for: .normal) 只管默认态,highlighted 和 disabled 还是系统默认的灰。所以每个状态要各配一遍:
less
let button = UIButton(type: .system)
button.setTitle("提交", for: .normal)
button.setTitleColor(.white, for: .normal)
button.setTitleColor(.white.withAlphaComponent(0.6), for: .highlighted)
button.setTitleColor(.secondaryLabel, for: .disabled)
button.setBackgroundImage(UIImage(color: .systemBlue), for: .normal)
button.setBackgroundImage(UIImage(color: .systemBlue.withAlphaComponent(0.7)), for: .highlighted)
button.setBackgroundImage(UIImage(color: .systemGray4), for: .disabled)
代码里 highlighted 那两行是最容易漏的。缺了它们,按钮按下去纹丝不动,用户会怀疑自己有没有点到。这是从安卓过来最容易忽略的一条:安卓的按压反馈是框架层面白送的,iOS 要逐状态手动配。
上面用到的 UIImage(color:) 不是系统 API,是个 1x1 拉伸的纯色图扩展,UIKit 里生成纯色背景图的标准做法。
创建按钮时的 type 参数有 8 个可选值:
| 值 | 用途 | 备注 |
|---|---|---|
.custom |
完全自定义,无系统样式 | 图片显示原色,不响应 tintColor |
.system |
系统标准按钮 | iOS 7 起的首选,自动响应 tintColor |
.plain |
无模糊背景的系统按钮 | iOS 13+ |
.close |
关闭面板的 × 按钮 | iOS 13+ |
.detailDisclosure |
详情箭头 ⓘ | 表格行常用 |
.infoLight |
浅色背景信息按钮 | |
.infoDark |
深色背景信息按钮 | |
.contactAdd |
添加联系人 ➕ | |
.roundedRect |
圆角矩形 | Deprecated (iOS 7 起弃用,等同 .system) |
.system 和 .custom 的区别在图片着色上很关键:.system 类型的按钮会自动用 tintColor 给图片上色,.custom 不会,图片显示原色。这条 Stack Overflow 上有多人独立验证(最早 iOS 9 / Xcode 7.3 时代的回答,后续回答结论一致)。
iOS 15 之后有套新的配置体系。UIButton.Configuration 把标题、副标题、图片、背景、圆角、内边距收进一个结构体,四个预置样式开箱可用:
ini
var config = UIButton.Configuration.filled() // .plain() / .gray() / .tinted() / .filled()
config.title = "加入购物车"
config.subtitle = "剩余 12 件"
config.image = UIImage(systemName: "cart.badge.plus")
config.imagePlacement = .trailing
config.imagePadding = 8
config.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20)
config.cornerStyle = .capsule
let addButton = UIButton(configuration: config, primaryAction: UIAction { _ in
self.addToCart()
})
// 状态变化时改配置,全部集中在这一个闭包里
addButton.configurationUpdateHandler = { button in
var config = button.configuration
config?.image = button.isHighlighted
? UIImage(systemName: "cart.fill.badge.plus")
: UIImage(systemName: "cart.badge.plus")
config?.showsActivityIndicator = button.isSelected
button.configuration = config
}
configurationUpdateHandler 在按钮状态变化时被调用,isHighlighted / isSelected / isEnabled 变化时系统会自动安排一次更新。如果依赖的不是按钮自身属性(比如上面那个"剩余 12 件"来自外部数据),改完数据要手动调 button.setNeedsUpdateConfiguration() 触发刷新。
这套配置有个容易踩的硬边界。Apple 官方文档对 configuration 属性的原话是:
When using a configuration, the button ignores deprecated methods and properties of UIButton.
哪些算 deprecated?Xcode 会直接报编译警告,原文是 'imageEdgeInsets' is deprecated: first deprecated in iOS 15.0 - This property is ignored when using UIButtonConfiguration。imageEdgeInsets / titleEdgeInsets / contentEdgeInsets 这三个 iOS 15 起全部失效,要改用 config.imagePadding / config.titlePadding / config.contentInsets。
热区是这周第二个坑。Apple HIG 中文官网的原话是"按钮需要至少 44x44 点的点击区域"。WWDC24 有个 session 补了更细的口径:iPhone / iPad 上默认目标 44×44 pt,次要 UI 最低可以压到 28 pt。
一个 24×24 的图标按钮,视觉尺寸是 24,热区得是 44。UIKit 里扩热区的标准做法是重写 point(inside:with:):
swift
class ExpandedHitButton: UIButton {
var hitInset: CGFloat = 0
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let expanded = bounds.insetBy(dx: -hitInset, dy: -hitInset)
return expanded.contains(point)
}
}
// 24pt 的图标按钮,四周各扩 10pt 凑到 44
let closeButton = ExpandedHitButton(type: .system)
closeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
closeButton.hitInset = 10
重写 point(inside:with:) 而不重写 hitTest(_:with:),是因为 hitTest 的默认实现会依次调用 point(inside:with:) 判断要不要往子视图里找。只改 point(inside:with:) 就够,改 hitTest 容易把整个查找流程搞乱。
hitTest 有两个边界值得单独记。Apple 文档原话是它"ignores view objects that are hidden, that have disabled user interactions, or that have an alpha level less than 0.01"。注意是 0.01 ,不是中文博客常写的 0.1。差别很大,alpha = 0.05 的视图照样能接收点击。
另一条更容易撞:
This method doesn't report points that lie outside the view's bounds as hits, even if they actually lie within one of the view's subviews. This situation can occur if the view's
clipsToBoundsproperty is false.
也就是说,把子视图画到父视图 bounds 外面(父视图 clipsToBounds = false),那部分区域点不到。扩热区时如果按钮嵌在一个小容器里,很容易出现"热区扩出来了但点不动"的情况:容器父视图的 point(inside:with:) 先返回了 false,根本轮不到子视图。
防重复点击这块,有个区分值得单独拎出来:按钮用 throttle,搜索框用 debounce。
上一周我用 Task + cancel 给搜索框做了 300ms 防抖,那是 debounce,等用户停下来再发请求。按钮不能这么干,ZOOZ Engineering 的技术博客把这点讲得很直白:debounce 会让首次点击也要等一个间隔才响应,"so navigation, UI change or whatever the button action is, will feel laggy",而且用户发现点了没反应会更想继续点。throttle 是首次立即响应,冷却期内的点击直接丢弃。
swift
final class ThrottleButton: UIButton {
private var lastHit: TimeInterval = 0
var throttleInterval: TimeInterval = 0.5
override func sendAction(_ action: Selector, to target: Any?, for event: UIEvent?) {
let now = CACurrentMediaTime()
guard now - lastHit >= throttleInterval else { return }
lastHit = now
super.sendAction(action, to: target, for: event)
}
}
重写 sendAction(_:to:for:) 而不是在外面套 addTarget 包装,是因为所有事件(包括 .touchUpInside、.touchDown)最终都要经过这个方法,拦在这里最彻底。
如果项目里按钮很多、逐个换类不现实,奇舞团移动端团队 2018 年在掘金公开过一套方案:用 Runtime 的 method_exchangeImplementations 交换 sendAction:to:forEvent:,配合 objc_setAssociatedObject 给每个按钮挂一个 eventInterval 属性,一次交换全局生效。好处是零侵入,代价是 method swizzling 会影响所有 UIButton 实例(包括系统内部的),出问题不好排查。我这个项目规模用不上,纯手写的子类更可控。但如果是一个几百个按钮的老工程,swizzling 那套反而是更实际的选择。
事件传递的顺序,第 1 周写 UILabel 局部点击时用过一次,这周扩热区又离不开它,索性记完整:
objectivec
UIApplication → UIWindow → rootView.hitTest()
├─ point(inside:with:) 返回 false → 返回 nil,整条分支不再往下找
└─ 返回 true → 倒序遍历 subviews(从最上层开始)递归 hitTest
└─ 子视图都返回 nil → 返回 self
找到 hit-test view 之后才走响应链:touchesBegan → 沿 next 往上抛,直到有人处理或者被丢弃。事件传递是自上而下(父到子),事件响应是自下而上(子到父)。
UIButton 之外,iOS 还有一组"开关类"控件,都继承 UIControl,但事件类型跟按钮不一样:
| 控件 | 事件 | 关键属性 |
|---|---|---|
UISwitch |
.valueChanged |
isOn / onTintColor / thumbTintColor |
UISlider |
.valueChanged |
value / minimumValue / maximumValue / isContinuous |
UIStepper |
.valueChanged |
value / stepValue / minimumValue / maximumValue |
UISegmentedControl |
.valueChanged |
selectedSegmentIndex / selectedSegmentTitles |
这里有个新手必踩的坑:它们不用 .touchUpInside,用 .valueChanged 。Stack Overflow 上 2014 年那条"UISwitch not working if dragged"至今还在被引用。用 .touchUpInside 挂 UISwitch,点击能触发,拖动就不触发了。区别是 .touchUpInside 只在"手指在控件内抬起"时发一次,而开关类控件的值可能因为拖动、VoiceOver、键盘操作而变化。
UISlider 有个默认值要注意:isContinuous 默认 true,手指拖动过程中会持续 发 .valueChanged(每秒几十次)。如果每次都发网络请求,等于自己在做 DoS 攻击。关掉它变成"松手才发一次",或者像搜索框那样做节流。
UISlider 还有个 Apple 文档里明确写的边界:
Use either a custom tint color or a custom image, but not both.
先设了 minimumTrackTintColor 再设 setMinimumTrackImage,tint 会被清掉。文档原话是"setting a new minimum track image for any state clears any custom tint color"。反过来设 thumbTintColor 也会清掉自定义 thumb 图。自定义图片的话,所有状态都要设,漏一个状态就显示系统默认图。
图片着色这块,alwaysTemplate 是减少切图数量的关键。安卓那边每个状态一套图是常态,iOS 这边一套图配 tintColor 就够:
ini
let icon = UIImage(named: "heart")?.withRenderingMode(.alwaysTemplate)
button.setImage(icon, for: .normal)
button.tintColor = .systemPink
alwaysTemplate 让 UIKit 忽略图片的原始颜色,只保留 alpha 通道当蒙版,用 tintColor 填充。三种渲染模式里 .automatic 是默认(按上下文决定),.alwaysOriginal 强制原色,.alwaysTemplate 强制模板。
配这个有个前提:按钮 type 得能响应 tintColor。前面说过,.system 会,.custom 不会。另外 adjustsImageWhenHighlighted 默认 true,高亮时系统会给图片加一层变暗效果。如果 tintColor 本身就很深,按下去会糊成一团,这时候要设成 false。
实践
防重复点击 ,奇舞团移动端团队(360)2018 年在掘金公开过三档方案:单按钮 enabled 置灰控制、cancelPreviousPerformRequests + performSelector 组合、Runtime 方法交换全局拦截。他们的结论是前两种"在需要对大量 UIButton 做控制的场景中会比较不方便",推荐第三档。enabled 置灰那档有个副作用:按钮会变灰,用户看到的反馈是"这按钮不让我点了"而不是"正在处理中",请求完成后还要记得恢复,漏恢复就永久禁用。
throttle 与 debounce 的选型,ZOOZ Engineering 的公开文章给了明确判断:debounce 适合"等用户停下来"的场景(搜索框输入),throttle 适合"立即响应但要防止连点"的场景(按钮点击、分页加载、滚动加载)。他们给的按钮间隔是 0.5 秒。
44×44 热区这条是 Apple HIG 的硬性建议,不是某家公司的偏好。HIG 原文要求"至少 44x44 点",WWDC24 补的口径是默认 44、次要 UI 最低 28。
技术清零表
| 技术 | 它是什么 | 工程价值 | 常见坑 |
|---|---|---|---|
UIControl.State |
按钮状态位掩码(normal/highlighted/selected/disabled/focused) | 按状态配样式 | 给 normal 设的样式不会自动沿用到其他状态 |
UIButton.ButtonType |
按钮类型枚举(8 个可用值) | 决定系统样式与 tintColor 行为 | .roundedRect 已弃用;.custom 不响应 tintColor |
UIButton.Configuration |
iOS 15+ 声明式按钮配置 | 副标题、图片位置、圆角、内边距一站式配置 | 启用后忽略 deprecated API(imageEdgeInsets 等失效) |
configurationUpdateHandler |
状态变化时更新配置的闭包 | 集中管理所有状态的样式变化 | 依赖外部数据时需手动 setNeedsUpdateConfiguration() |
point(inside:with:) |
判断点是否落在视图内 | 扩大按钮热区的标准切入点 | 父视图先返回 false 时子视图热区扩了也点不到 |
hitTest(_:with:) |
查找最深层命中的视图 | 理解事件传递 | 忽略 alpha < 0.01 的视图(不是 0.1) |
| HIG 44×44 pt | 最小点击区域规范 | 保证可点性 | 视觉尺寸 24pt 的图标,热区仍要 44pt |
sendAction(_:to:for:) 重写 |
拦截所有控件事件的统一入口 | 实现 throttle 防重复点击 | 比在 addTarget 外层包装更彻底 |
| throttle vs debounce | 两种限流策略 | throttle 首次立即响应,debounce 等停止后响应 | 按钮用 throttle,搜索框用 debounce,用反了会卡顿 |
.valueChanged |
开关类控件的值变化事件 | UISwitch / UISlider / UIStepper / UISegmentedControl 统一事件 | 用 .touchUpInside 会导致拖动不触发 |
UISlider.isContinuous |
拖动时是否持续发事件 | 控制事件频率 | 默认 true,拖动中每秒几十次事件 |
alwaysTemplate |
图片渲染模式(忽略原色,用 tintColor 填充) | 一套图配多色,减少切图 | .custom 类型按钮上不生效;需配合 adjustsImageWhenHighlighted |
下周是 UIImageView 全功能 + 图片加载优化,对照安卓第 4 周。contentMode 那九个值跟安卓 ScaleType 的映射关系、.alwaysTemplate 在 UIImageView 上的行为差异、以及列表里图片的异步加载取消,是那周要啃的东西。"热区扩了点不到"这个坑(父视图 bounds 拦截子视图的点击)如果跟图片视图叠在一起,大概率还要再撞一次。