SwiftUI中Marquee内按钮无法点击及滚动异常问题
SwiftUI跑马灯按钮无响应问题解决及无ScrollView实现方案
问题根源
- 点击遮挡:原Marquee修饰符的body直接返回两个视图(测量用的
Color.clear和目标内容),SwiftUI会将其视为垂直布局的TupleView,且Color.clear默认会拦截点击事件,导致下层的按钮无法响应。 - 依赖ScrollView:原实现的滚动逻辑误依赖了ScrollView的横向容器,实际上跑马灯效果可通过直接控制视图offset实现,无需ScrollView包裹。
修复方案
1. 修改Marquee修饰符核心逻辑
struct Marquee: ViewModifier { let duration: TimeInterval let direction: Direction let autoreverse: Bool @State private var offset = CGFloat.zero @State private var parentSize = CGSize.zero @State private var contentSize = CGSize.zero func body(content: Content) -> some View { // 用ZStack组合测量视图和内容,确保内容在顶层可接收点击 ZStack(alignment: .leading) { // 测量父视图宽度的占位视图,不遮挡内容 Color.clear .frame(height: 0) .measureSize { size in parentSize = size updateAnimation(sizeChanged: true) } content .measureSize { size in contentSize = size updateAnimation(sizeChanged: true) } .offset(x: offset) .animationObserver(for: offset, onComplete: { updateAnimation(sizeChanged: false) }) } // 限制容器宽度为父视图宽度,确保内容超出时触发滚动 .frame(width: parentSize.width) } private func updateAnimation(sizeChanged: Bool) { // 仅当内容宽度超过父视图时才触发滚动 guard contentSize.width > parentSize.width else { offset = 0 return } let scrollDistance = contentSize.width + parentSize.width if sizeChanged || !autoreverse { offset = direction == .rightToLeft ? parentSize.width : -scrollDistance } withAnimation(.linear(duration: duration)) { offset = direction == .rightToLeft ? -scrollDistance : parentSize.width } } enum Direction { case leftToRight, rightToLeft } } // 以下辅助代码保持原实现不变 extension View { func marquee(duration: TimeInterval, direction: Marquee.Direction = .rightToLeft, autoreverse: Bool = false) -> some View { self.modifier(Marquee(duration: duration, direction: direction, autoreverse: autoreverse)) } } struct SizePreferenceKey: PreferenceKey { static var defaultValue: CGSize = .zero static func reduce(value: inout CGSize, nextValue: () -> CGSize) { value = nextValue() } } struct MeasureSizeModifier: ViewModifier { func body(content: Content) -> some View { content.background(GeometryReader { geometry in Color.clear.preference(key: SizePreferenceKey.self, value: geometry.size) }) } } extension View { func measureSize(perform action: @escaping (CGSize) -> Void) -> some View { self.modifier(MeasureSizeModifier()) .onPreferenceChange(SizePreferenceKey.self, perform: action) } } public struct AnimationObserverModifier<Value: VectorArithmetic>: AnimatableModifier { private let observedValue: Value private let onChange: ((Value) -> Void)? private let onComplete: (() -> Void)? public var animatableData: Value { didSet { notifyProgress() } } public init(for observedValue: Value, onChange: ((Value) -> Void)?, onComplete: (() -> Void)?) { self.observedValue = observedValue self.onChange = onChange self.onComplete = onComplete animatableData = observedValue } public func body(content: Content) -> some View { content } private func notifyProgress() { DispatchQueue.main.async { onChange?(animatableData) if animatableData == observedValue { onComplete?() } } } } public extension View { func animationObserver<Value: VectorArithmetic>(for value: Value, onChange: ((Value) -> Void)? = nil, onComplete: (() -> Void)? = nil) -> some View { self.modifier(AnimationObserverModifier(for: value, onChange: onChange, onComplete: onComplete)) } }
2. 调整业务视图,移除ScrollView
struct MyView: View { var body: some View { let users = ["user1", "user2", "user3", "user4", "user5", "user6"] HStack(spacing: 8) { ForEach(users, id: \.self) { user in if user != users.first { Text("•") } Button(action: { print("tapped \(user)") }, label: { Text(user) .padding(.horizontal, 4) }) } } .frame(height: 20) .marquee(duration: 10) } }
关键修复点说明
- ZStack布局:将测量用的
Color.clear和内容视图放在ZStack中,确保内容视图处于顶层,点击事件不会被拦截。 - 滚动逻辑优化:基于内容宽度和父视图宽度的总和计算滚动距离,确保内容完全滚动出视野后自动重置循环。
- 移除ScrollView:通过
frame限制容器宽度,让HStack内容超出时触发跑马灯滚动,无需依赖ScrollView的滚动容器。
内容的提问来源于stack exchange,提问作者nickcoding2
相关产品推荐
相关产品推荐

