ScrollView内のButtonのハイライト挙動の変化

SwiftUI.ScrollView 内にある SwiftUI.Button のインタラクションの挙動がXcode/OSバージョンによって変わっています。調べた限りXcode 16以降からのiOS 18*1で挙動の違いがありました。

何が起きるか

Xcode 16以降のiOS 18では、SwiftUI.ScrollView 内に Button を配置している場合、タップした瞬間にタッチイベントは発火してもハイライトされません。通常は軽くタップするとボタンが一瞬透明になりますよね?あの状態です。

import SwiftUI

struct ContentView: View {
    var body: some View {
        ScrollView {
            Button("Button") {
                print("action")
            }
        }
        .padding()
    }
}

ButtonStyle を明示的に指定して値の変化を監視すると何が起きているかわかりやすいです。厳密には ButtonStyleConfiguration.isPressed が軽くタップするだけでは反映されない状態なので、ボタンの opacity などが変化しません。タップする時間が0.5秒くらいあれば isPressed が反映されます。自前の ButtonStyle を作ってスケールエフェクトなどをつけている場合、この挙動はユーザー体験を悪くしてしまう可能性があります。

import SwiftUI

struct TestButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .onChange(of: configuration.isPressed) {
                print("configuration.isPressed: \(configuration.isPressed)") // 軽くタップするだけでは変化しない
            }
    }
}

struct ContentView: View {
    var body: some View {
        ScrollView {
            Button("Button") {
                print("action")
            }
            .buttonStyle(TestButtonStyle())
        }
        .padding()
    }
}

iOS 18での対応策

UIScrollView には delaysContentTouches というプロパティがあります。

developer.apple.com

この delaysContentTouches を無効にしてあげると従来の挙動に戻すことができることがわかっています。

SwiftUI.ScrollView の場合、この挙動を変更する術がないため、 swiftui-introspectを使って制御する必要があります。

import SwiftUI
import SwiftUIIntrospect

struct ContentView: View {
    var body: some View {
        ScrollView {
            Button("Button") {
                print("action")
            }
        }
        .introspect(.scrollView, on: .iOS(.v18)) { scrollView in
            scrollView.delaysContentTouches = false
        }
        .padding()
    }
}

各地の ScrollView につけてまわる必要があるので素敵な解決策ではないですね...。他にもいい方法があれば教えてください。

Xcode/OSバージョンごとやUIButtonとの違い

XcodeやOSバージョンごとに違うのはもちろんそうですが、 UIButton かどうかでも違いがあります。

UIButton SwiftUI.Button ScrollView内のUIButton ScrollView内のSwiftUI.Button
Xcode 15/iOS 17 ⚪︎ ⚪︎ × ⚪︎
Xcode 15/iOS 18 ⚪︎ ⚪︎ × ⚪︎
Xcode 16/iOS 17 ⚪︎ ⚪︎ × ⚪︎
Xcode 16/iOS 18 ⚪︎ ⚪︎ × ×
Xcode 16/iOS 26 ⚪︎ ⚪︎ × ⚪︎
Xcode 26/iOS 17 ⚪︎ ⚪︎ × ⚪︎
Xcode 26/iOS 18 ⚪︎ ⚪︎ × ×
Xcode 26/iOS 26 ⚪︎ ⚪︎ × ⚪︎

UIButtonの場合は一貫してハイライトが効かないので、Xcode 16/iOS 18で挙動を合わせたのかな〜と思ったのですが、iOS 26で元の挙動に戻っているため不具合だったのかもしれません。ちなみにこの挙動をFeedback(FB16848138)済みでした。

検証コード

適当にコピペして試してみてください。

import SwiftUI

struct TestButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .foregroundColor(.white)
            .frame(maxWidth: .infinity, minHeight: 44)
            .background(configuration.isPressed ? .blue.opacity(0.5) : .blue)
            .cornerRadius(10)
            .onChange(of: configuration.isPressed) {
                print("configuration.isPressed: \(configuration.isPressed)")
            }
    }
}

struct ContentView: View {
    var body: some View {
        VStack {
            switftUIButton("SwiftUI/Outside")
            uikitButton("UIKit/Outside")

            ScrollView {
                switftUIButton("SwiftUI/Inside")
                uikitButton("UIKit/Inside")
            }
        }
        .padding()
    }

    private func switftUIButton(_ title: String) -> some View {
        Button(title) {
            print("action")
        }
        .buttonStyle(TestButtonStyle())
    }

    private func uikitButton(_ title: String) -> some View {
        UIKitButton(title: title) {
            print("action")
        }
        .frame(height: 44)
    }
}

struct UIKitButton: UIViewRepresentable {
    let title: String
    let action: (() -> Void)?

    init(title: String, action: (() -> Void)? = nil) {
        self.title = title
        self.action = action
    }

    func makeUIView(context: Context) -> some UIButton {
        _UIKitButton(title: title, action: action)
    }

    func updateUIView(_ uiView: UIViewType, context: Context) { }
}


final class _UIKitButton: UIButton {
    init(title: String, action: (() -> Void)?) {
        super.init(frame: .null)
        translatesAutoresizingMaskIntoConstraints = false

        setTitle(title, for: .normal)
        setTitleColor(.white, for: .normal)

        backgroundColor = .systemBlue
        layer.cornerRadius = 10

        addAction(.init { _ in action?() }, for: .touchUpInside)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override var isHighlighted: Bool {
        didSet {
            print("isHighlighted: \(isHighlighted)")
            backgroundColor = isHighlighted ? .systemBlue.withAlphaComponent(0.5) : .systemBlue
        }
    }
}

*1:正確には18.1 ~ 18.5で発生します。18.0は体感ほとんど発生しない