You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI中Marquee内按钮无法点击及滚动异常问题

SwiftUI跑马灯按钮无响应问题解决及无ScrollView实现方案

问题根源

  1. 点击遮挡:原Marquee修饰符的body直接返回两个视图(测量用的Color.clear和目标内容),SwiftUI会将其视为垂直布局的TupleView,且Color.clear默认会拦截点击事件,导致下层的按钮无法响应。
  2. 依赖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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.16 05:20:31