UIScrollView与UIStackView的完美组合打造灵活滚动布局
优雅实现水平滚动工具栏:UIScrollView与UIStackView的完美组合
本文将介绍如何通过UIScrollView与UIStackView的黄金组合,实现一个灵活高效的水平滚动工具栏。这种组合方案具有以下优势:
- 自动布局:UIScrollView提供滚动容器,UIStackView管理动态布局
- 命令模式:采用协议化设计实现按钮与业务逻辑解耦
- 动态扩展:支持按钮的灵活增减
- 性能优化:原生控件组合带来流畅滚动体验
- 代码简洁:告别复杂的布局计算
核心实现原理
1. 视图层级结构
ButtonBarView
└── UIScrollView
└── UIStackView (Horizontal)
└── 自定义按钮视图(SLVerticalButtonView)×N
2. 关键布局策略
// 核心约束设置
// StackView 约束到 ScrollView 的 ContentLayoutGuide
[_stackView.topAnchor constraintEqualToAnchor:_scrollView.contentLayoutGuide.topAnchor],
[_stackView.leadingAnchor constraintEqualToAnchor:_scrollView.contentLayoutGuide.leadingAnchor],
[_stackView.trailingAnchor constraintEqualToAnchor:_scrollView.contentLayoutGuide.trailingAnchor],
[_stackView.bottomAnchor constraintEqualToAnchor:_scrollView.contentLayoutGuide.bottomAnchor],
// StackView 高度与 ScrollView 的 FrameLayoutGuide 一致
[_stackView.heightAnchor constraintEqualToAnchor:_scrollView.frameLayoutGuide.heightAnchor]
3. 动态内容更新
- (void)setCommands:(NSArray<id<Command>> *)commands {
// 清空旧视图
// 动态生成新按钮
// 更新布局
}
// 更新布局
- (void)updateStackViewWidthIfNeeded {
[self.scrollView layoutIfNeeded]; // 立即布局以获取准确宽度
CGFloat contentWidth = self.stackView.frame.size.width;
CGFloat scrollViewWidth = self.scrollView.bounds.size.width;
if (contentWidth < scrollViewWidth && self.commands.count > 0) {
if (!self.stackViewWidthConstraint) {
// 添加约束使stackView宽度等于scrollView的宽度
self.stackViewWidthConstraint = [self.stackView.widthAnchor constraintEqualToAnchor:self.scrollView.frameLayoutGuide.widthAnchor];
self.stackViewWidthConstraint.priority = UILayoutPriorityRequired;
[self.stackViewWidthConstraint setActive:YES];
[self.scrollView layoutIfNeeded]; // 应用新约束
}
} else {
if (self.stackViewWidthConstraint) {
[self.stackViewWidthConstraint setActive:NO];
self.stackViewWidthConstraint = nil;
[self.scrollView layoutIfNeeded];
}
}
}
完整实现代码(Objective-C)
//====================
// 命令协议
//====================
@protocol Command <NSObject>
@property (strong, nonatomic) ExtendCompositional *descriptor;
- (void)execute;
@end
//====================
// 命令工厂协议
//====================
@protocol CommandFactory <NSObject>
- (NSArray<id<Command>> *)commandsForContext:(UIViewController *)context;
@end
//====================
// 基础命令抽象类
//====================
@interface Command : NSObject <Command>
@property (weak, nonatomic) UIViewController *context;
+ (instancetype)commandWithContext:(UIViewController *)context;
@end
@implementation Command
@synthesize descriptor = _descriptor;
+ (instancetype)commandWithContext:(UIViewController *)context {
Command *cmd = [self new];
cmd.context = context;
return cmd;
}
- (void)execute { /* 子类实现 */ }
@end
//====================
// 工具栏视图
//====================
@interface ButtonBarView : UIView<UIScrollViewDelegate>
@property (strong, nonatomic) UIScrollView *scrollView;
@property (strong, nonatomic) UIStackView *stackView;
@property (strong, nonatomic) NSArray<id<Command>> *commands;
@end
@interface ButtonBarView ()
@property (nonatomic, strong) NSLayoutConstraint *stackViewWidthConstraint;
@end
@implementation ButtonBarView
- (instancetype)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
[self setupScrollSystem];
}
return self;
}
- (void)setupScrollSystem {
_scrollView = [[UIScrollView alloc] initWithFrame:self.bounds];
_scrollView.showsHorizontalScrollIndicator = NO;
_scrollView.delegate = self;
_stackView = [[UIStackView alloc] init];
_stackView.axis = UILayoutConstraintAxisHorizontal;
_stackView.alignment = UIStackViewAlignmentFill;
_stackView.distribution = UIStackViewDistributionFillEqually;
_stackView.spacing = 20;
[_scrollView addSubview:_stackView];
[self addSubview:_scrollView];
// 布局约束
_stackView.translatesAutoresizingMaskIntoConstraints = NO;
_scrollView.translatesAutoresizingMaskIntoConstraints = NO;
[NSLayoutConstraint activateConstraints:@[
[_scrollView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
[_scrollView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
[_scrollView.topAnchor constraintEqualToAnchor:self.topAnchor],
[_scrollView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
// StackView 约束到 ScrollView 的 ContentLayoutGuide
[_stackView.topAnchor constraintEqualToAnchor:_scrollView.contentLayoutGuide.topAnchor],
[_stackView.leadingAnchor constraintEqualToAnchor:_scrollView.contentLayoutGuide.leadingAnchor],
[_stackView.trailingAnchor constraintEqualToAnchor:_scrollView.contentLayoutGuide.trailingAnchor],
[_stackView.bottomAnchor constraintEqualToAnchor:_scrollView.contentLayoutGuide.bottomAnchor],
// StackView 高度与 ScrollView 的 FrameLayoutGuide 一致
[_stackView.heightAnchor constraintEqualToAnchor:_scrollView.frameLayoutGuide.heightAnchor]
]];
}
- (void)setCommands:(NSArray<id<Command>> *)commands {
_commands = commands;
// 清空旧按钮
[_stackView.arrangedSubviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
// 动态生成按钮
[commands enumerateObjectsUsingBlock:^(id<Command> command, NSUInteger idx, BOOL *stop) {
SLVerticalButtonView *button = [[SLVerticalButtonView alloc] initWithFrame:CGRectZero target:command action:@selector(execute)];
button.image = command.descriptor.image;
button.title = command.descriptor.title;
button.identifier = command.descriptor.identifier;
[button setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal];
[self.stackView addArrangedSubview:button];
}];
[self updateStackViewWidthIfNeeded];
}
- (void)layoutSubviews {
[super layoutSubviews];
[self updateStackViewWidthIfNeeded];
}
- (void)updateStackViewWidthIfNeeded {
[self.scrollView layoutIfNeeded]; // 立即布局以获取准确宽度
CGFloat contentWidth = self.stackView.frame.size.width;
CGFloat scrollViewWidth = self.scrollView.bounds.size.width;
if (contentWidth < scrollViewWidth && self.commands.count > 0) {
if (!self.stackViewWidthConstraint) {
// 添加约束使stackView宽度等于scrollView的宽度
self.stackViewWidthConstraint = [self.stackView.widthAnchor constraintEqualToAnchor:self.scrollView.frameLayoutGuide.widthAnchor];
self.stackViewWidthConstraint.priority = UILayoutPriorityRequired;
[self.stackViewWidthConstraint setActive:YES];
[self.scrollView layoutIfNeeded]; // 应用新约束
}
} else {
if (self.stackViewWidthConstraint) {
[self.stackViewWidthConstraint setActive:NO];
self.stackViewWidthConstraint = nil;
[self.scrollView layoutIfNeeded];
}
}
}
@end
使用示例
1. 创建命令工厂
@interface CommandFactory : NSObject <CommandFactory>
@end
@implementation CommandFactory
- (NSArray<id<Command>> *)commandsForContext:(UIViewController *)context {
return @[
[AddPhotoCommand commandWithContext:context],
[AddTextCommand commandWithContext:context],
[AddBackgroundCommand commandWithContext:context],
[AddFilterCommand commandWithContext:context]
];
}
@end
2. 在ViewController中使用
- (void)viewDidLoad {
[super viewDidLoad];
// 创建工具栏
ButtonBarView *toolbar = [[ButtonBarView alloc] initWithFrame:CGRectMake(0, 100, self.view.bounds.size.width, 80)];
// 获取命令列表
id<CommandFactory> factory = [CommandFactory new];
toolbar.commands = [factory commandsForContext:self];
[self.view addSubview:toolbar];
}
3. 自定义按钮示例(SLVerticalButtonView)
// 实现垂直排列的图标+文字按钮
@implementation SLVerticalButtonView
- (instancetype)initWithFrame:(CGRect)frame target:(id)target action:(SEL)action {
self = [super initWithFrame:frame];
// 添加图片视图和文字标签
// 设置点击事件
[self addTarget:target action:action forControlEvents:UIControlEventTouchUpInside];
return self;
}
@end
关键点解析
-
布局锚点选择:
contentLayoutGuide
:决定可滚动内容范围frameLayoutGuide
:限制可视区域范围
-
性能优化:
- 复用UIStackView的自动布局
- 避免复杂图层结构
-
扩展性设计:
- 遵循命令模式
- 支持动态命令配置
效果展示
水平滚动工具栏将自动根据内容多少:
- 内容少时:居中显示
- 内容多时:支持左右滑动
- 自动维持统一高度
这种实现方案完美结合了UIScrollView的滚动能力和UIStackView的自动布局优势,特别适合需要动态生成水平排列元素的场景,避免选择UICollectionView层级结构比较多的视图。通过命令模式的引入,使得每个按钮的行为可以灵活配置,极大提高了代码的可维护性和扩展性。