Table of contents
Open Table of contents
前言
iOS 11 引入了 UISwipeActionsConfiguration + UIContextualAction,替代了旧的 UITableViewRowAction。不过这组 API 的自定义能力比较有限:
- 按钮宽度不可控,系统自动计算
- 没有 spacing 的概念,按钮之间紧贴
- 视觉自定义只支持
image+backgroundColor,没法放复杂内容
实际开发中常见的卡片式按钮、设计稿间距对齐、复杂内容排版等需求,系统 API 无法直接覆盖。
开源方案(比如 UICustomSwipeActions)提供了类似思路,但通常更偏向圆形按钮定制。本文记录一种扩展到 custom view 模式的实现方式。
核心目标就三条:
- 自定义按钮宽度
- 自定义按钮间距
- 支持三种按钮样式:默认、圆形、任意自定义 View
设计原则:不重写 swipe 容器,不改系统动画,只在布局末尾补充自定义逻辑。
一、系统 Swipe Actions 的内部结构
实现前需要先了解系统 swipe actions 的内部结构。通过运行时探索,可以梳理出以下关系:
UITableViewCell
└── UISwipeActionPullView ← 滑动时露出的容器
├── UISwipeActionStandardButton ← 每个 action 对应一个 button
├── UISwipeActionStandardButton
└── ...
关键私有类:
UISwipeActionPullView:滑动手势驱动的容器 view,负责 reveal 动画、布局 subviewUISwipeActionStandardButton:每个 action 对应的按钮,内部自带 label / imageView 等原生视觉
关键私有字段:
_actions(pullView 上):存放本次 swipe 对应的UIContextualAction数组_cellEdge(pullView 上):标记 swipe 是从左边(UIRectEdgeLeft)还是右边(UIRectEdgeRight)出来的buttonWidth(每个 button 上):控制按钮的实际宽度
整体策略如下:
swizzle
UISwipeActionPullView的layoutSubviews,在系统布局完成后,读_actions拿到 action 配置,修改buttonWidth控制宽度,清掉系统原生视觉,再塞入自定义 view 控制样式。
二、整体架构
整体由三个部分协作完成:
CustomSwipeActionsConfiguration (继承 UISwipeActionsConfiguration)
├── 整组默认配置:width / spacing / style
└── setter 自动下发到每个 action
UIContextualAction (Category,用 Associated Object 补属性)
├── 在 action 上保存实际配置:width / spacing / style
├── customViewInsets / customViewProvider
└── helper 方法统一设置 customView 模式
UISwipeActionPullView (Swizzle layoutSubviews)
├── 系统布局完成后触发
├── 读取 _actions 和 _cellEdge
├── 修改 buttonWidth (KVC)
└── 注入自定义视觉 view
三层职责分明:
| 层 | 职责 |
|---|---|
| Configuration | 整组默认值,setter 即时下发 |
| Action | 保存单个 action 的实际值(配置下发后的落点) |
| PullView (Swizzle) | 在布局时读 action 的值,执行实际的视图修改 |
三、关键实现细节
3.1 用 Associated Object 给 UIContextualAction 补属性
不建议通过继承 UIContextualAction 扩展(调用方通常通过系统 factory 创建 action,Category 更适合在不改变创建方式的前提下附加配置),所以用 Category + Associated Object:
static const void *kCustomPreferredWidthKey = &kCustomPreferredWidthKey;
- (void)setCustomPreferredWidth:(CGFloat)customPreferredWidth {
objc_setAssociatedObject(self, kCustomPreferredWidthKey,
@(customPreferredWidth), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (CGFloat)customPreferredWidth {
NSNumber *value = objc_getAssociatedObject(self, kCustomPreferredWidthKey);
return value ? value.doubleValue : 0;
}objc
一共补了 5 个属性:width、spacing、style、customViewInsets、customViewProvider。
默认 style 是 Default,表示不接管——这是重要的安全措施:没有显式开启自定义时,所有系统 swipe action 行为不变。
另外提供了一个收口方法:
- (void)customSetCustomViewProvider:(CustomSwipeActionViewProvider)provider
insets:(UIEdgeInsets)insets {
self.customPreferredButtonStyle = CustomSwipeButtonStyleCustomView;
self.customViewInsets = insets;
self.customViewProvider = provider;
}objc
把 customView 模式的三个配置一步完成,降低调用方遗漏配置项的概率。
3.2 Configuration 子类:setter 即时下发
继承 UISwipeActionsConfiguration,重写三个 setter:
- (void)setPreferredButtonWidth:(CGFloat)preferredButtonWidth {
_preferredButtonWidth = preferredButtonWidth;
[self applyConfigToActions];
}
- (void)applyConfigToActions {
for (UIContextualAction *action in self.actions) {
action.customPreferredWidth = self.preferredButtonWidth;
// ... style, spacing 同理
}
}objc
为什么 setter 里要立刻下发?因为调用方通常这样写:
CustomSwipeActionsConfiguration *config = [CustomSwipeActionsConfiguration
configurationWithActions:@[action]];
config.preferredButtonWidth = 74;
config.preferredButtonSpacing = 12.5;
return config;objc
此时 swizzle 还没触发,但 action 上需要已经保存好配置值。原因有二:一是 UISwipeActionPullView 内部能拿到的只有 _actions,没有稳定路径回到配置对象;二是 configuration 对象此时可能已经被释放。
3.3 Swizzle 的核心:只动一个方法
整个方案只 swizzle 了一个方法:UISwipeActionPullView 的 layoutSubviews。
+ (void)swizzleSwipePullViewIfNeeded {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class pullViewClass = NSClassFromString(@"UISwipeActionPullView");
if (!pullViewClass) return;
Method original = class_getInstanceMethod(pullViewClass,
@selector(layoutSubviews));
Method swizzled = class_getInstanceMethod([UIView class],
@selector(customSwipe_layoutSubviews));
if (!original || !swizzled) return;
// 先把自定义方法挂到 pullView 上
BOOL added = class_addMethod(pullViewClass,
@selector(customSwipe_layoutSubviews),
method_getImplementation(swizzled),
method_getTypeEncoding(swizzled));
if (!added) return;
// 再交换
Method installed = class_getInstanceMethod(pullViewClass,
@selector(customSwipe_layoutSubviews));
method_exchangeImplementations(original, installed);
});
}objc
customSwipe_layoutSubviews 定义在一个 UIView 分类里。先用 class_addMethod 把它挂到 pullView 上(因为分类是加在 UIView 上的,不是 pullView 上),再 method_exchangeImplementations。
swizzle 后的执行流程:
- (void)customSwipe_layoutSubviews {
// 1. 先调原始 layoutSubviews(保证系统动画和布局正确)
[self customSwipe_layoutSubviews];
// 2. 再执行自定义布局
[self applyCustomLayout];
}objc
这个顺序很关键。如果反过来,或者完全不调原始方法,系统 reveal 动画就会被破坏。
3.4 读私有数据:_actions 和 _cellEdge
swizzle 后,self 就是 pullView,通过 KVC 拿数据:
- (void)applyCustomLayout {
// 从 pullView 内部拿 actions,辅助函数会先确认它是 NSArray,
// 再过滤出 UIContextualAction 对象
NSArray *actions = ActionsForPullView(self);
if (!actions.count) return;
// 第一个 action 是 Default 就不处理
UIContextualAction *first = actions.firstObject;
if (first.customPreferredButtonStyle == CustomSwipeButtonStyleDefault) return;
// 拿 swipe 方向
UIRectEdge cellEdge = [[self valueForKey:@"_cellEdge"] unsignedIntegerValue];
// 清掉 pullView 背景,避免挡住自定义内容
self.backgroundColor = [UIColor clearColor];
// 遍历系统 button,逐个处理
[self.subviews enumerateObjectsUsingBlock:^(UIView *subview, NSUInteger idx, BOOL *stop) {
if (![subview isKindOfClass:[UIButton class]] || idx >= actions.count) return;
UIButton *button = (UIButton *)subview;
UIContextualAction *action = actions[idx];
// 清掉系统原生视觉(label / imageView 等)
[self removeSystemSubviewsFrom:button];
// 改 buttonWidth(关键!)
CGFloat w = action.customPreferredWidth > 0 ? action.customPreferredWidth : 74;
CGFloat s = MAX(action.customPreferredSpacing, 0);
[button setValue:@(w + s) forKey:@"buttonWidth"];
// 注入自定义 view
[self installInjectedViewInto:button action:action edge:cellEdge];
}];
}objc
说明:该方案以第一个 action 的 style 决定整组是否接管,不适合在同一组 swipe actions 中混合 Default / Circular / CustomView 三种样式。同一个 configuration 里的 actions 应当使用同一种 style。
3.5 控制宽度的关键:KVC 写 buttonWidth
[button setValue:@(buttonWidth) forKey:@"buttonWidth"];objc
这是宽度定制的关键点。系统 button 有一个私有 buttonWidth 属性,控制按钮在 swipe reveal 中的实际宽度。如果不改这个值,即使自定义 view 已经加入 button,reveal 宽度也不会变,spacing 也不会生效。
关于 spacing:这里的 spacing 本质上是视觉间距。实现方式是把
buttonWidth设为preferredWidth + preferredSpacing,然后调整内部 view 位置。系统 button 的可点击区域会包含这份间距,但并没有修改UISwipeActionPullView的系统 subview 间距模型。
3.6 注入自定义视图:两种模式
清除系统原生 subview 后,向 button 内注入自定义视觉 view。通过 tag 标记 injected view,方便复用和去重:
static NSInteger const kInjectedViewTag = 98241;
// 查找已安装的 view
- (UIView *)findInstalledViewIn:(UIButton *)button {
for (UIView *subview in button.subviews) {
if (subview.tag == kInjectedViewTag) return subview;
}
return nil;
}objc
Circular 模式
创建一个内嵌的圆形 view,用 backgroundColor + image 展示:
// 圆形视觉按钮
@interface CustomSwipeVisualButton : UIView
@property (nonatomic, strong) UIView *backgroundView;
@property (nonatomic, strong) UIImageView *imageView;
@end
@implementation CustomSwipeVisualButton
- (void)layoutSubviews {
[super layoutSubviews];
self.backgroundView.frame = self.bounds;
self.backgroundView.layer.cornerRadius = self.bounds.size.width / 2.0;
// image 居中...
}
@endobjc
每次布局时都会把 action 的 backgroundColor 和 image 同步到视觉按钮上。首次创建时直接使用目标 frame;已安装过的视觉按钮在后续 layout 更新时,会用一个 0.5s 动画过渡到新 frame。
CustomView 模式
通过 block 回调让调用方返回任意 view,再塞进系统 button 内:
if (action.customPreferredButtonStyle == CustomSwipeButtonStyleCustomView
&& action.customViewProvider) {
UIView *customView = [self findInstalledViewIn:button];
if (!customView) {
customView = action.customViewProvider(action, button);
customView.tag = kInjectedViewTag;
customView.userInteractionEnabled = NO;
[button addSubview:customView];
}
CGRect targetFrame = FrameForInjectedView(pullView, button, action, cellEdge);
customView.frame = targetFrame;
}objc
这里 不带动画。因为 swipe reveal 本身就是系统动画驱动的 layoutSubviews 循环,custom view 自己再做动画会出现滞后、位置不对齐。
Custom view 的 frame 用 customViewInsets 控制缩进:
CGFloat customHeight = CGRectGetHeight(button.bounds) - insets.top - insets.bottom;
if (customHeight <= 0) {
customHeight = CGRectGetHeight(button.bounds);
}
return CGRectMake(insets.left,
insets.top,
preferredWidth - insets.left - insets.right,
customHeight);objc
说明:高度如果被 insets 扣成非正数,会回退到 button 高度;宽度没有额外 clamp,所以
customViewInsets.left + right应小于preferredWidth。
3.7 injected view 位置计算
这是布局中最复杂的部分,需要根据 reveal 状态调整 x 偏移:
CGFloat preferredWidth = action.customPreferredWidth > 0
? action.customPreferredWidth : 74.0;
CGFloat preferredSpacing = MAX(action.customPreferredSpacing, 0);
CGFloat buttonWidth = preferredWidth + preferredSpacing;
// 判断是否完全 reveal
BOOL fullyRevealed = CGRectGetWidth(pullView.frame)
== CGRectGetWidth(pullView.superview.frame);
CGFloat offset = fullyRevealed ? preferredSpacing : 0;
// 根据方向计算 x
CGFloat x = (cellEdge == UIRectEdgeLeft ? preferredSpacing : 0)
+ (cellEdge == UIRectEdgeLeft ? -offset : offset);
// y 居中
CGFloat y = (CGRectGetHeight(pullView.frame) / 2.0)
- (buttonWidth / 2.0) + (preferredSpacing / 2.0);
return CGRectMake(x, y, preferredWidth, preferredWidth);objc
关键点:
- fullyRevealed 时,spacing 体现在 offset 上,微调内部 view 位置
- 未完全 reveal 时,不需要 spacing 偏移
- 该方式不修改整个 pullView 的 frame,而是根据 reveal 状态微调 button 内部可视内容的位置
- 浮点宽度用
==判断 fullyRevealed 是一种经验判断,不是严谨状态机;更稳健的写法是用阈值(如fabs(diff) < 0.5)或>=近似判断 - 代码中
actions[idx]直接按pullView.subviews的索引映射 action,隐含假设系统 button 的顺序与 action 数组完全一致且中间没有其他 subview 插入——该假设依赖于系统私有视图结构
四、完整调用流程回顾
从配置创建到最终渲染的完整链路:
1. 创建 UIContextualAction,设置 image / backgroundColor / handler
2. 创建 CustomSwipeActionsConfiguration,设置 width / spacing / style
└─ setter 触发 → applyConfigToActions → 值下发到每个 action
3. 如果需要 customView:
└─ [action customSetCustomViewProvider:block insets:...]
└─ 自动设置 style = CustomView + insets + provider
4. 返回 configuration
└─ tableView:trailingSwipeActionsConfigurationForRowAtIndexPath:
5. 用户滑动 cell
└─ 系统创建 UISwipeActionPullView + UISwipeActionStandardButton
└─ 系统执行原生 reveal 动画(layoutSubviews 循环)
6. swizzled layoutSubviews 触发
└─ 调原始 layoutSubviews(保留系统动画)
└─ 读 _actions 拿配置
└─ 判断 style ≠ Default → 继续
└─ 读 _cellEdge 拿方向
└─ 遍历每个 button:
├─ 清系统原生 subview
├─ KVC 写 buttonWidth ← 这是宽度和间距生效的关键
└─ 注入自定义 view(circular 或 customView)
五、使用示例
// 1. 创建 action
UIContextualAction *deleteAction = [UIContextualAction
contextualActionWithStyle:UIContextualActionStyleDestructive
title:nil
handler:^(UIContextualAction *action, UIView *sourceView,
void (^completionHandler)(BOOL)) {
// handle delete
completionHandler(YES);
}];
deleteAction.image = [UIImage imageNamed:@"icon_delete"];
deleteAction.backgroundColor = [UIColor redColor];
// 2. 创建 configuration
CustomSwipeActionsConfiguration *config = [CustomSwipeActionsConfiguration
configurationWithActions:@[deleteAction]];
config.preferredButtonStyle = CustomSwipeButtonStyleCircular;
config.preferredButtonWidth = 60;
config.preferredButtonSpacing = 8;
return config;objc
CustomView 模式示例:
UIContextualAction *reportAction = [UIContextualAction
contextualActionWithStyle:UIContextualActionStyleNormal
title:nil handler:^(UIContextualAction *action, UIView *sourceView,
void (^completionHandler)(BOOL)) {
// handle report
completionHandler(YES);
}];
CustomSwipeActionsConfiguration *config = [CustomSwipeActionsConfiguration
configurationWithActions:@[reportAction]];
// 先设置 config 的 style,确保不会被默认值覆盖
config.preferredButtonStyle = CustomSwipeButtonStyleCustomView;
config.preferredButtonWidth = 74;
config.preferredButtonSpacing = 12.5;
// 再用 helper 方法设置 customView
[reportAction customSetCustomViewProvider:^UIView *(UIContextualAction *action,
UIView *actionButton) {
UILabel *label = [[UILabel alloc] init];
label.text = @"举报";
label.textColor = [UIColor whiteColor];
label.font = [UIFont systemFontOfSize:14];
label.textAlignment = NSTextAlignmentCenter;
return label;
} insets:UIEdgeInsetsMake(6, 0, 6, 0)];
return config;objc
六、注意事项
-
私有 API 风险:
UISwipeActionPullView、_actions、_cellEdge、buttonWidth都是私有的,iOS 大版本升级可能导致崩溃。使用时需要做好容错(NSClassFromString判空、valueForKeytry-catch 等)。 -
不接管 Default 模式:只有 style ≠ Default 时才执行自定义逻辑。这是主要的安全兜底,确保不显式开启自定义时,所有系统 swipe 行为完全不变。
-
cell 复用的安全性:系统 button 通常会随着 swipe 容器重新创建或重新布局,injected view 通过 tag 查找并复用,每次 layout 时重新匹配,避免重复插入。
-
线程安全:所有操作都在
layoutSubviews中执行,天然在主线程,不需要额外加锁。
总结
核心思想是:不推翻系统的 swipe 机制,只在布局末尾做补充。通过 swizzle UISwipeActionPullView.layoutSubviews,在这个关键布局点读取 action 配置、修改按钮宽度、注入自定义视觉。
三个技术要点要记住:
- Associated Object 给
UIContextualAction补属性 - KVC 读写私有字段(
_actions、_cellEdge、buttonWidth) - Swizzle 后先调原始方法再执行自定义逻辑