第 3 周:UIButton 点了没反应,因为它默认就没有按压态

写安卓的时候,按钮是"最不用想"的控件。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) 只管默认态,highlighteddisabled 还是系统默认的灰。所以每个状态要各配一遍:

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 UIButtonConfigurationimageEdgeInsets / 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 clipsToBounds property 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"至今还在被引用。用 .touchUpInsideUISwitch,点击能触发,拖动就不触发了。区别是 .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 的映射关系、.alwaysTemplateUIImageView 上的行为差异、以及列表里图片的异步加载取消,是那周要啃的东西。"热区扩了点不到"这个坑(父视图 bounds 拦截子视图的点击)如果跟图片视图叠在一起,大概率还要再撞一次。

相关推荐
思盛iOS签名上架17 小时前
App苹果签名有什么作用?
macos·ios·cocoa
CocoaKier19 小时前
Facebook广告后台收不到Adjust回传数据,我们排查了一个月终于找到了根因
ios·facebook
FairGuard手游加固2 天前
iOS 游戏加固技术解析:代码混淆、资源加密、内存保护与重签名检测
安全·游戏·macos·unity·ios·objective-c
思盛iOS签名上架2 天前
iOS企业签名原理是什么?
macos·ios·cocoa
Daniel_Coder2 天前
我用 SwiftUI + GRDB 做了一个「按时吃药·服药提醒」App
ios·swiftui·swift·widgetkit
Patrick_Wilson2 天前
iOS 第三方浏览器图片下载失败问题
前端·ios·浏览器
悟空瞎说3 天前
NSNotificationCenter(NotificationCenter)官方文档白话翻译 + API 详解 + Swift 实战
ios
末代iOS程序员华仔3 天前
iOS个人开发者账号申请为啥会被卡住
ios·swift·figma