Skip to content

iOS 自定义 Swipe Actions:绕过系统限制实现宽度、间距与样式定制

轩辕十四
Published date:

Table of contents

Open Table of contents

前言

iOS 11 引入了 UISwipeActionsConfiguration + UIContextualAction,替代了旧的 UITableViewRowAction。不过这组 API 的自定义能力比较有限:

实际开发中常见的卡片式按钮、设计稿间距对齐、复杂内容排版等需求,系统 API 无法直接覆盖。

开源方案(比如 UICustomSwipeActions)提供了类似思路,但通常更偏向圆形按钮定制。本文记录一种扩展到 custom view 模式的实现方式。

核心目标就三条:

  1. 自定义按钮宽度
  2. 自定义按钮间距
  3. 支持三种按钮样式:默认、圆形、任意自定义 View

设计原则:不重写 swipe 容器,不改系统动画,只在布局末尾补充自定义逻辑。


一、系统 Swipe Actions 的内部结构

实现前需要先了解系统 swipe actions 的内部结构。通过运行时探索,可以梳理出以下关系:

UITableViewCell
  └── UISwipeActionPullView          ← 滑动时露出的容器
        ├── UISwipeActionStandardButton  ← 每个 action 对应一个 button
        ├── UISwipeActionStandardButton
        └── ...

关键私有类:

关键私有字段:

整体策略如下:

swizzle UISwipeActionPullViewlayoutSubviews,在系统布局完成后,读 _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 个属性:widthspacingstylecustomViewInsetscustomViewProvider

默认 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 了一个方法:UISwipeActionPullViewlayoutSubviews

+ (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 的 backgroundColorimage 同步到视觉按钮上。首次创建时直接使用目标 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

关键点:


四、完整调用流程回顾

从配置创建到最终渲染的完整链路:

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

六、注意事项

  1. 私有 API 风险UISwipeActionPullView_actions_cellEdgebuttonWidth 都是私有的,iOS 大版本升级可能导致崩溃。使用时需要做好容错(NSClassFromString 判空、valueForKey try-catch 等)。

  2. 不接管 Default 模式:只有 style ≠ Default 时才执行自定义逻辑。这是主要的安全兜底,确保不显式开启自定义时,所有系统 swipe 行为完全不变。

  3. cell 复用的安全性:系统 button 通常会随着 swipe 容器重新创建或重新布局,injected view 通过 tag 查找并复用,每次 layout 时重新匹配,避免重复插入。

  4. 线程安全:所有操作都在 layoutSubviews 中执行,天然在主线程,不需要额外加锁。


总结

核心思想是:不推翻系统的 swipe 机制,只在布局末尾做补充。通过 swizzle UISwipeActionPullView.layoutSubviews,在这个关键布局点读取 action 配置、修改按钮宽度、注入自定义视觉。

三个技术要点要记住:

  1. Associated ObjectUIContextualAction 补属性
  2. KVC 读写私有字段_actions_cellEdgebuttonWidth
  3. Swizzle 后先调原始方法再执行自定义逻辑
Next
C 语言重拾【九】联合类型 union