【SwiftUI】Level up your ScrollView
scrollDisabled
scrollDisabled
モディファイアを使用することで、スクロールの無効化を設定することができます。
disabled
モディファイアでもスクロールの無効化を設定できますが、同時にスクロールビュー内にあるボタンなども無効化してしまいます。
disabled | scrollDisabled |
---|---|
![]() |
![]() |
使用したコードはこちら
struct ContentView: View {
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(10000..<10100) { _ in
Rectangle()
.frame(height: 100)
}
}
}
.scrollDisabled(true) // ✅
}
}
defaultScrollAnchor
defaultScrollAnchor
モディファイアを使用することで、スクロールビュー表示時に、コンテントをどこから表示するかを設定することができます。
下の例は、引数anchor
に.top
、.center
、.bottom
をそれぞれ設定した例です。
.top | .center | .bottom |
---|---|---|
![]() |
![]() |
![]() |
使用したコードはこちら
struct ContentView: View {
var body: some View {
ScrollView {
LazyVStack(spacing: nil) {
ForEach(0..<1000) { i in
Text(i.description)
.padding(.vertical)
.frame(maxWidth: .infinity)
.background(.gray.opacity(0.2))
}
}
}
.defaultScrollAnchor(.top) // ✅
.contentMargins(.horizontal, 16, for: .scrollContent)
}
}
scrollClipDisabled
scrollClipDisabled
モディファイアを使用することで、スクロールビューのクリップの無効化を設定することができます。
引数disabled
にBool
を渡し、無効化の設定を行います。デフォルト引数として、true
が設定されています。
scrollClipDisabledなし | scrollClipDisabledあり |
---|---|
![]() |
![]() |
使用したコードはこちら
struct ContentView: View {
var body: some View {
ScrollView {
VStack(spacing: nil) {
ForEach(10000..<10500) { i in
Text(i.description)
}
}
.frame(maxWidth: .infinity)
}
.scrollClipDisabled() // ✅
.frame(width: 300, height: 500)
.border(.gray)
}
}
scrollIndicators
scrollIndicators
モディファイアを使用することで、スクロールインジケーターの表示に関する設定をすることができます。
引数visibility
に対して、.hidden
を渡すことで、スクロールインジケーターを非表示にすることができます。
scrollIndicators(.hidden)なし | scrollIndicators(.hidden)あり |
---|---|
![]() |
![]() |
使用したコードはこちら
struct ContentView: View {
var body: some View {
ScrollView {
LazyVStack(spacing: 10) {
ForEach(10000..<10500) { i in
Text(i.description)
}
}
}
.scrollIndicators(.hidden) // ✅
}
}
scrollIndicatorsFlash
scrollIndicatorsFlash
モディファイアを使用することで、スクロールビューが表示された時、もしくは特定の値が変更された時に、スクロールインジケーターを点滅させることができます。
まずは、スクロールビューが表示された時にスクロールインジケーターが点滅する例です。
.scrollIndicatorsFlash(onAppear: true)
を付与した場合、付与しなかった場合を提示します。右上に注目してください。
.scrollIndicatorsFlash(onAppear: true)なし | .scrollIndicatorsFlash(onAppear: true)あり |
---|---|
![]() |
![]() |
使用したコードはこちら
struct ContentView: View {
@State private var isShowingMyScrollView = false
var body: some View {
if isShowingMyScrollView == true {
ScrollView {
LazyVStack(spacing: nil) {
ForEach(10000..<10500) { i in
Text(i.description)
}
}
}
.scrollIndicatorsFlash(onAppear: true) // ✅
} else {
Button("Show MyScrollView") {
isShowingMyScrollView = true
}
}
}
}
次に、特定の値が変更された時にスクロールインジケーターが点滅する例です。
3秒おきに変数bool
の値を変転させています。先ほどと同様、右上に注目してください。
.scrollIndicatorsFlash(trigger: true)なし | .scrollIndicatorsFlash(trigger: true)あり |
---|---|
![]() |
![]() |
使用したコードはこちら
struct ContentView: View {
@State private var bool = false
var body: some View {
ScrollView {
LazyVStack(spacing: nil) {
ForEach(0..<10, id: \.self) { _ in
Text(bool.description)
.font(.largeTitle.bold())
.containerRelativeFrame([.horizontal, .vertical])
}
}
}
.scrollIndicatorsFlash(trigger: bool) // ✅
.task {
while true {
bool.toggle()
try? await Task.sleep(for: .seconds(3.0))
}
}
}
}
scrollDismissKeyboard
scrollDismissKeyboard
モディファイアを使用することで、スクロール時のキーボードの挙動を設定することができます。
引数mode
にScrollDismissesKeyboardMode
を渡し、スクロール時のキーボードの挙動を設定します。
ScrollDismissesKeyboardMode | Description |
---|---|
.immediately | スクロールされるとすぐにキーボードを閉じる。 |
.interactively | スクロールによりキーボードは閉じられないが、スワイプダウンによりキーボードを閉じることができる。 |
.never | スクロールによりキーボードは閉じられない。 |
.automatic | SwiftUIにスクロール時のキーボードの挙動を任せる。 |
下の例では、引数mode
に.immediately
、.interactively
、.never
をそれぞれ設定しています。
.immediately | .interactively | .never |
---|---|---|
![]() |
![]() |
![]() |
使用したコードはこちら
struct ContentView: View {
@State private var text = "Hello, world."
var body: some View {
ScrollView {
VStack(spacing: nil) {
ForEach(10000..<10100) { i in
if i == 10020 {
TextField(i.description, text: $text)
.textFieldStyle(.roundedBorder)
} else {
Text(i.description)
.frame(maxWidth: .infinity)
.frame(height: 30)
.background(.gray.opacity(0.1))
}
}
}
}
.safeAreaPadding(.horizontal)
.scrollDismissesKeyboard(.immediately) // ✅
}
}
safeAreaInset
safeAreaInset
モディファイアを使用することで、特定のエッジに対し、ビューを配置することができます。
overlay
モディファイアと似ていますが、overlay
モディファイアは単なる上塗りであり、safeAreaInset
モディファイアは、セーフエリアの領域としてビューを差し込みます。
この差異が顕著に現れるのがスクロールビューの末尾です。overlay
モディファイアを使用した場合では、ボタンが最後のビューに重なってしまいますが、safeAreaInset
を使用した場合では、ボタンが最後のビュー(10028と10029)に重なってしまうことはありません。
safeAreaInset | overlay |
---|---|
![]() |
![]() |
使用したコードはこちら
struct ContentView: View {
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(10000..<10030) { i in
Text(i.description)
}
}
}
.safeAreaInset(edge: .bottom) {
Button("Back to Top") {}
.buttonStyle(.borderedProminent)
.controlSize(.extraLarge)
}
// .overlay(alignment: .bottom) {
// Button("Back to Top") {}
// .buttonStyle(.borderedProminent)
// .controlSize(.extraLarge)
// }
}
}
safeAreaPadding
safeAreaPadding
モディファイアを使用することで、特定のエッジに対し、パディングを設定することができます。
safeAreaPadding
モディファイアは、セーフエリアの領域としてパディングを設定します。
padding
モディファイアと似ていますが、padding
モディファイアは、スクロールビューのクリップを伴うのに対し、safeAreaPadding
モディファイアは、セーフエリアの領域としてパディングを設けるため、スクロールビューのクリップが伴うことはありません。
safeAreaPadding | padding |
---|---|
![]() |
![]() |
使用したコードはこちら
struct ContentView: View {
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 16) {
ForEach(0..<30) { _ in
Rectangle()
.frame(width: 100, height: 100)
}
}
}
.safeAreaPadding(.horizontal, 20) // ✅
// .padding(.horizontal, 20)
}
}
contentMargins
contentMargins
モディファイアを使用することで、スクロールビューにマージンを設定することができます。
先述したsafeAreaPadding
モディファイアと似ていますが、主な違いとしてcontentMargins
モディファイアは、スクロールコンテントのみ、もしくはスクロールインジケーターのみにマージンを設定できる点が挙げられます。
引数placement
にContentMarginPlacement
を渡し、対象を設定します。
下の例では、引数placement
に.scrollContent
、.scrollIndicator
、.automatic
をそれぞれ設定しています。
.scrollContent | .scrollIndicator |
---|---|
![]() |
![]() |
モディファイア適用なし | .automatic |
---|---|
![]() |
![]() |
使用したコードはこちら
struct ContentView: View {
var body: some View {
ScrollView {
LazyVStack(spacing: nil) {
ForEach(0..<100) { i in
Rectangle()
.fill(i.isMultiple(of: 2) ? .secondary : .tertiary)
.frame(height: 100)
}
}
}
.contentMargins(50, for: .scrollContent) // ✅
}
}
scrollBounceBehavior
scrollBounceBehavior
モディファイアを使用することで、スクロールビューのバウンス動作を設定することができます。
本記事では、.scrollBounceBehavior(.basedOnSize)
を使用したケースのみを取り上げます。
コンテントがスクロールビューのサイズを超えている場合、バウンス動作が発生します。
逆に、コンテントのサイズがスクロールビューのサイズを超えていない場合、バウンス動作は発生しません。
下の例では、四角形が5個の時はバウンス動作が発生していないのに対し、四角形が50個の時はバウンス動作が発生しています。
Rectangle ×5 | Rectangle ×50 |
---|---|
![]() |
![]() |
使用したコードはこちら
struct ContentView: View {
var body: some View {
ScrollView {
LazyVStack(spacing: 10) {
ForEach(0..<50) { _ in // 5 or 50
Rectangle()
.fill(.secondary)
.frame(height: 100)
}
}
}
.scrollBounceBehavior(.basedOnSize) // ✅
}
}
refreshable
refreshable
モディファイアを使用することで、スワイプダウンによるリフレッシュアクションを設定することができます。
引数action
は、非同期クロージャであるため、内部でTask
を生成せずにそのまま非同期処理(Swift Concurrency)を書くことができます。
リフレッシュアクションが実行されると、プログレスビューが表示され、更新が終わるまでプログレスビューは表示され続けます。
使用したコードはこちら
struct ContentView: View {
@State private var timestamps: [Date] = [.now]
var body: some View {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(timestamps.reversed(), id: \.self) { timestamp in
Text(timestamp.description)
.font(.title)
}
}
}
.refreshable { // ✅
try? await Task.sleep(for: .seconds(2.0))
timestamps.append(.now)
}
}
}
scrollContentBackground
scrollContentBackground
モディファイアを使用することで、List
、もしくはForm
の背景色を非表示にすることができます。
List
とForm
には、デフォルトで背景色(UIColor.secondarySystemBackground
)がついており、background
モディファイアで背景色を上書きしようとしても機能しません。
.scrollContentBackground(.hidden)
を付与することで、背景色を設定できるようになります。
.scrollContentBackground(.hidden)なし | .scrollContentBackground(.hidden)あり |
---|---|
![]() |
![]() |
使用したコードはこちら
struct ContentView: View {
var body: some View {
List(10000..<10030) { i in
Text(i.description)
}
.scrollContentBackground(.hidden) // ✅
.background(.blue)
}
}
scrollTargetLayout
scrollTargetLayout
モディファイアを使用することで、コンテナをスクロールターゲットレイアウトとして設定することができます。
このモディファイアは、後述する.scrollTargetBehavior
モディファイア、onScrollTargetVisibilityChange
モディファイア、scrollPosition
モディファイアの補助的な役割を担います。
スクロールビュー内のメインコンテナ(LazyHStack
やVStack
などのレイアウトコンテナ)に対して付与します。
scrollTargetBehavior
scrollTargetBehavior
モディファイアを使用することで、スクロール時の振る舞いを設定することができます。
引数behavior
にScrollTargetBehavior(Opaque Type)
を渡して、スクロール時の振る舞いを設定します。
PagingScrollTargetBehavior.paging
では、スクロールビューのコンテナ領域の高さ、もしくは幅がスクロールの単位となり、PagingScrollTargetBehavior.viewAligned
では、スクロール停止時にスクロールビューのエッジとビューのエッジが揃えられます。
.viewAligned
を設定する場合は、scrollTargetLayout
モディファイアをメインコンテナに対し付与する必要があります。
下の例は、水平方向のスクロールビューに.paging
を適用した例と.viewAligned
を適用した例です。
.paging
では、一度のスクロール量がスクロールビューのコンテナ領域の幅となっており、.viewAligned
では、スクロール停止時にスクロールビューの左端とビューの左端が揃えられています。
horizontal × paging | horizontal × viewAligned |
---|---|
![]() |
![]() |
下の例は、垂直方向のスクロールビューに.paging
を適用した例と.viewAligned
を適用した例です。
.paging
では、一度のスクロール量がスクロールビューのコンテナ領域の高さとなっており、そのままではスクロールの度にズレが発生してしまうため、GeometryReader
を使用して調整しています。
.viewAligned
では、スクロール停止時にスクロールビューのトップとビューのトップが揃えられています。(引数limitBehavior
に.alwaysByOne
を設定していますが、勢いよくスクロールするとなぜかビュー二つ分のスクロールがされます。)
vertical × paging | vertical × viewAligned |
---|---|
![]() |
![]() |
使用したコードはこちら(horizontal × paging)
struct ContentView: View {
private let colors: [Color] = [.pink, .blue, .orange, .green]
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 16) {
ForEach(0..<30, content: numberTile)
}
}
.scrollTargetBehavior(.paging) // ✅
}
func numberTile(_ i: Int) -> some View {
Image(systemName: i.description + ".circle")
.foregroundStyle(.white)
.font(.largeTitle)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(colors[i % colors.count])
.frame(width: 100, height: 100)
}
}
使用したコードはこちら(horizontal × viewAligned)
struct ContentView: View {
private let colors: [Color] = [.pink, .blue, .orange, .green]
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 16) {
ForEach(0..<30, content: numberTile)
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned) // ✅
}
func numberTile(_ i: Int) -> some View {
Image(systemName: i.description + ".circle")
.foregroundStyle(.white)
.font(.largeTitle)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(colors[i % colors.count])
.frame(width: 100, height: 100)
}
}
使用したコードはこちら(vertical × paging)
struct ContentView: View {
private let colors: [Color] = [.pink, .blue, .orange, .green]
var body: some View {
GeometryReader { geometryProxy in
let safeAreaInsets = geometryProxy.safeAreaInsets
let topSafeAreaInset = safeAreaInsets.top
let bottomSafeAreaInset = safeAreaInsets.bottom
ScrollView {
LazyVStack(spacing: .zero) {
ForEach(0..<20) { i in
content
.containerRelativeFrame(.vertical) { length, _ in
length - topSafeAreaInset - bottomSafeAreaInset
}
.frame(maxWidth: .infinity)
.padding(.top, topSafeAreaInset)
.padding(.bottom, bottomSafeAreaInset)
.background(colors[i % colors.count].gradient)
}
}
}
.scrollTargetBehavior(.paging) // ✅
.ignoresSafeArea()
.contentMargins(.top, topSafeAreaInset, for: .scrollIndicators)
.contentMargins(.bottom, bottomSafeAreaInset, for: .scrollIndicators)
}
}
var content: some View {
Text("Hello, world.")
.font(.largeTitle)
.fontWeight(.semibold)
.foregroundStyle(.white)
}
}
使用したコードはこちら(vertical × viewAligned)
struct ContentView: View {
private let colors: [Color] = [.pink, .blue, .orange, .green]
var body: some View {
ScrollView {
LazyVStack(spacing: .zero) {
ForEach(0..<20) { i in
VStack(spacing: .zero) {
content
}
.containerRelativeFrame([.vertical, .horizontal])
.background(colors[i % colors.count].gradient)
.clipped()
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned(limitBehavior: .alwaysByOne)) // ✅
}
var content: some View {
Text("Hello, world.")
.font(.largeTitle)
.fontWeight(.semibold)
.foregroundStyle(.white)
}
}
onScrollGeometryChange
onScrollGeometryChange
モディファイアを使用することで、スクロールビューのレイアウト情報?が更新された際の処理を設定することができます。
transform
クロージャが提供するScrollGeometory
インスタンスを通じて、スクロールビューのレイアウト情報を取得可能です。
取得できる情報には、スクロールビューの実際のサイズ(コンテナ領域)、コンテントの長さ、インセット、オフセット、ビジュアルレクト(可視領域)などがあります。
transform
クロージャでは、レイアウト情報を用いて処理に必要な値を生成し、ビューの更新はaction
クロージャで行わなければなりません。
下の例では、一定量スクロールされている場合に、Back to Top ボタンを表示させています。
使用したコードはこちら
struct ContentView: View {
@State private var isShowingBackToTopButton = false
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(10000..<10050) { i in
Text(i.description)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(.gray.opacity(0.3))
}
}
}
.onScrollGeometryChange(for: Bool.self) { scrollGeometry in // ✅
scrollGeometry.contentOffset.y > 0
} action: { _, newValue in
isShowingBackToTopButton = newValue
}
.overlay(alignment: .top) {
if isShowingBackToTopButton == true {
backToTopButton
}
}
}
var backToTopButton: some View {
Button("Back to Top") {}
.font(.title3)
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
}
}
onGeometryChange
onGeometryChange
モディファイアを使用することで、ビューのレイアウト情報が更新された際の処理を設定することができます。
下の例は、ビューがスクロールビューのコンテナ領域に入った時に処理を実行する例、ビューがスクロールビューの真ん中に来た時に処理を実行する例です。
transform
クロージャでは、レイアウト情報を用いて処理に必要な値を生成し、ビューの更新はaction
クロージャで行わなければなりません。
自身の領域の75%以上がスクロールビュー内にある | 自身が真ん中に配置されてる |
---|---|
![]() |
![]() |
使用したコードはこちら
struct ContentView: View {
var body: some View {
ScrollView {
VStack(spacing: 16) {
ForEach(0..<50) { i in
if i == 5 {
SpecialRow(text: i.description)
} else {
NormalRow(text: i.description)
}
}
}
}
.border(.gray)
}
}
struct NormalRow: View {
var text: String
var body: some View {
Text(text)
.foregroundStyle(.background)
.frame(maxWidth: .infinity)
.frame(height: 100)
.background { Color.primary }
}
}
struct SpecialRow: View {
@State private var isVisible = false
var text: String
var body: some View {
Text(text)
.foregroundStyle(.background)
.frame(maxWidth: .infinity)
.frame(height: 100)
.background {
Rectangle()
.fill(isVisible == true ? .pink : .primary)
}
.onGeometryChange(for: Bool.self) { geometoryProxy in // ✅
let frame = geometoryProxy.frame(in: .scrollView)
let bounds = geometoryProxy.bounds(of: .scrollView)!
let area = CGRect(origin: .zero, size: bounds.size)
let intersection = frame.intersection(area)
let visibleHeight = intersection.size.height
return (visibleHeight / frame.size.height) > 0.75
} action: { isVisible in
self.isVisible = isVisible
}
}
}
使用したコードはこちら
struct ContentView: View {
var body: some View {
ScrollView {
VStack(spacing: 16) {
ForEach(0..<20) { i in
if i == 5 {
SpecialRow(text: i.description)
} else {
NormalRow(text: i.description)
}
}
}
}
.border(.blue, width: 3)
}
}
struct NormalRow: View {
var text: String
var body: some View {
Text(text)
.foregroundStyle(.background)
.frame(maxWidth: .infinity)
.frame(height: 100)
.background { Color.primary }
}
}
struct SpecialRow: View {
@State private var isCentered = false
var text: String
var body: some View {
Text(text)
.foregroundStyle(.background)
.frame(maxWidth: .infinity)
.frame(height: 100)
.background {
Rectangle()
.fill(isCentered == true ? .pink : .primary)
}
.onGeometryChange(for: Bool.self) { geometoryProxy in // ✅
let frame = geometoryProxy.frame(in: .scrollView)
let bounds = geometoryProxy.bounds(of: .scrollView)!
let verticalOffset = (bounds.size.height - frame.size.height) / 2
let centerArea = CGRect(
origin: .init(x: 0, y: verticalOffset),
size: frame.size
)
let intersection = frame.intersection(centerArea)
let visibleHeight = intersection.size.height
return (visibleHeight / frame.size.height) > 0.75
} action: { isCentered in
self.isCentered = isCentered
}
}
}
onScrollTargetVisibilityChange
onScrollTargetVisibilityChange
モディファイアを使用することで、スクロールビューのコンテナ領域内に表示されているビューの把握、及び変更があった際の処理を設定できます。
スクロールビューのコンテナ領域へのビューの表示に関しては、デフォルトでビューの50%が閾値として設定されています。引数threshold
にDoble
を渡すことで、任意の閾値に設定が可能です。
onScrollTargetVisibilityChange
モディファイアは、scrollTargetLayout
モディファイアとセットで使用します。もしscrollTargetLayout
モディファイアを忘れるとonScrollTargetVisibilityChange
モディファイアは機能しません。
下の例では、引数idType
にビューがもつIDの型を設定し、表示されているビューのIDをids
プロパティに格納しています。右側のサイドバーで、スクロールによるスクロールビューのコンテナ領域内のビューの変化が確認できます。
使用したコードはこちら
struct Fruit: Identifiable {
let name: String
let emoji: Character
var id: Character { emoji }
}
struct ContentView: View {
@State private var fruits: [Fruit] = [.init(name: "Apple", emoji: "🍎"), .init(name: "Banana", emoji: "🍌"), .init(name: "Cherry", emoji: "🍒"), .init(name: "Grapes", emoji: "🍇"), .init(name: "Strawberry", emoji: "🍓"), .init(name: "Orange", emoji: "🍊"), .init(name: "Pineapple", emoji: "🍍"), .init(name: "Watermelon", emoji: "🍉"), .init(name: "Kiwi", emoji: "🥝"), .init(name: "Peach", emoji: "🍑"), .init(name: "Lemon", emoji: "🍋"), .init(name: "Avocado", emoji: "🥑"), .init(name: "Green Apple", emoji: "🍏"), .init(name: "Mango", emoji: "🥭"), .init(name: "Coconut", emoji: "🥥"), .init(name: "Pear", emoji: "🍐"), .init(name: "Melon", emoji: "🍈"), .init(name: "Blueberry", emoji: "🫐")]
@State private var ids: [Character] = .init()
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(fruits) { fruit in
textTile(fruit.name)
}
}
.scrollTargetLayout()
}
.onScrollTargetVisibilityChange(idType: Fruit.ID.self) { ids in // ✅
withAnimation {
self.ids = ids
}
}
// .onScrollTargetVisibilityChange(idType: Fruit.ID.self, threshold: 0.3) { ids in
// withAnimation {
// self.ids = ids
// }
// }
.scrollIndicators(.never)
.overlay(alignment: .trailing, content: sideBar)
.border(.gray)
}
func textTile(_ text: String) -> some View {
Text(text)
.foregroundStyle(.background)
.font(.largeTitle.bold())
.frame(width: 200, height: 200)
.background(.primary)
}
func sideBar() -> some View {
VStack(spacing: 8) {
ForEach(ids, id: \.self) {
Text($0.description)
.font(.largeTitle)
}
}
.padding(.trailing, 4)
}
}
onScrollVisibilityChange
onScrollVisibilityChange
モディファイアを使用することで、ビューがスクロールビューのコンテナ領域内に入った時、もしくは出た時の処理を設定することができます。
スクロールビューのコンテナ領域へのビューの出入に関しては、デフォルトでビューの50%が閾値として設定されています。引数threshold
にDoble
を渡すことで、任意の閾値に設定が可能です。
ビューが表示された時点で、スクロールビューのコンテナ領域に入っている場合は、その時点で処理が呼び出されます。
下の例では、ビューがスクロールビューのコンテナ領域に入った時、もしくは出た時にisVisble
の値を反転し、四角形の色を変更しています。
使用したコードはこちら
struct ContentView: View {
@State private var isVisible = false
var body: some View {
ScrollView {
LazyVStack(spacing: 30) {
ForEach(0..<10) { i in
if i == 6 {
Rectangle()
.fill(isVisible ? .pink : .blue)
.frame(width: 320, height: 100)
.overlay(alignment: .trailing) {
VStack(spacing: .zero) {
Rectangle()
.fill(.black)
.frame(width: 20)
Rectangle()
.fill(.gray)
.frame(width: 20)
}
}
.onScrollVisibilityChange { isVisible in // ✅
self.isVisible = isVisible
}
// .onScrollVisibilityChange(threshold: 0.2) { isVisible in
// self.isVisible = isVisible
// }
} else {
Rectangle()
.fill(.gray.opacity(0.3))
.frame(height: 100)
}
}
}
}
.border(.black)
}
}
scrollTransition
scrollTransition
モディファイアを使用することで、ビューがスクロールビューのコンテナ領域内に入った時、もしくは出た時のトランジションを設定することができます。
下の例では、ビューがスクロールビューのコンテナ領域内に入っている時は透明度が1.0となり、出ている時は透明度が0.5となります。
トランジションがどのように適用されるかは、引数configuration
にScrollTransitionConfiguration
を渡すことで変更できます。デフォルト引数として、.interactive
が設定されており、ビューがスクロールビューのコンテナ領域内に入り始めると、もしくは出始めると、スクロール量に応じて徐々に変化していきます。
.animated
に設定した場合は、ビューの半分がスクロールビューのコンテナ領域内に入った時、もしくは出た時にアニメーション付きで一気に変化します。.identity
に設定した場合は、呼び出し元のビューが変化することはありません。
使用したコードはこちら
struct ContentView: View {
var body: some View {
ScrollView {
LazyVStack {
ForEach(0..<50) { _ in
Rectangle()
.frame(height: 100)
.scrollTransition(.interactive) { content, phase in // ✅
content
.opacity(phase.isIdentity ? 1.0 : 0.5)
}
}
}
}
.border(.pink, width: 3)
.safeAreaPadding(.vertical, 100)
}
}
visualEffect
visualEffect
モディファイアを使用することで、レイアウト情報に応じたエフェクトを適用できます。
visualEffect
モディファイアのeffect
クロージャは、呼び出し元のビューを表すEmptyVisualEffect
インスタンスと、レイアウト情報が格納されているGeometryProxy
インスタンスを提供します。
下の例では、ビューのY軸に応じて、色相とX軸のオフセットを変化させています。
使用したコードはこちら
struct ContentView: View {
var body: some View {
ScrollView {
VStack(spacing: 10) {
ForEach(0..<50) { i in
Text(i.description)
.foregroundStyle(.white)
.font(.largeTitle.bold())
.frame(width: 350, height: 150)
.background(.pink)
.visualEffect { emptyVisualEffectContent, geometryProxy in // ✅
emptyVisualEffectContent
.hueRotation(
Angle(degrees: geometryProxy.frame(in: .global).origin.y / 10)
)
.offset(x: (geometryProxy.frame(in: .global).origin.y - 62) * 0.1)
}
}
}
}
}
}
onScrollPhaseChange
onScrollPhaseChange
モディファイアを使用することで、ScrollPhase
が変化した際に実行する処理を設定できます。
ScrollPhase
は列挙型で、ケースは下記の通りです。
Case | Description |
---|---|
.interacting | 画面に触れながらスクロールしている間。 |
decelerating | 画面に触れずにスクロールしている間。(慣性) |
.idle | 停止している間。 |
.animating | アニメーションによりスクロールしている間。 |
.tracking | 詳細不明。 |
onScrollPhaseChange
モディファイアには、action
クロージャが引数にScrollPhaseChangeContext
もとるオーバーロードが存在し、ScrollPhase
が変化した際のスクロールビューのジオメトリも使用できます。
下の例では、ScrollPhase
の変遷を把握するために、画面上部にScrollPhase
の状態を表示しています。
使用したコードはこちら
struct ContentView: View {
@State private var scrollPosition: ScrollPosition = .init()
@State private var scrollPhase: ScrollPhase? = nil
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
ForEach(0..<100) { _ in
rowContent
.onTapGesture {
withAnimation {
scrollPosition.scrollTo(edge: .top)
}
}
}
}
}
.scrollPosition($scrollPosition)
.onScrollPhaseChange { _, newPhase in // ✅
scrollPhase = newPhase
}
.overlay(alignment: .top) {
if let text = scrollPhase?.debugDescription {
scrollPhaseText(text)
}
}
}
var rowContent: some View {
Text("Back to Top")
.foregroundStyle(.background)
.font(.title)
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.frame(height: 100)
.background(.primary)
}
func scrollPhaseText(_ text: String) -> some View {
Text(text)
.foregroundStyle(.white)
.font(.title3)
.fontWeight(.semibold)
.frame(width: 150, height: 40)
.background(.gray)
.clipShape(.capsule)
}
}
scrollPosition
scrollPosition
モディファイアを使用することで、指定のIDをもつビューまでの自動スクロール、スクロールビューが表示された時のスクロール位置の指定、焦点が当てられているビューの追跡を可能とします。
@State private var scrolledID: Color?
のようにバインディングを提供できるプロパティを用意します。このscrolledID
プロパティに対してIDとなる値を代入することで、代入された値をIDとしてもつビューまで自動スクロールさせることができます。
scrolledID
プロパティにIDとなる値を代入しておくと、そのIDをもつビューが表示されるようにスクロール位置を調整された状態でスクロールビューが表示されます。
手動スクロール時には、スクロールビューが焦点を当てているビューのIDがscrolledID
プロパティに代入されます。下の例では、scrolledID
プロパティの初期値はnil
となっていますが、.orange
を設定している場合、オレンジのカードが最初に表示されます。
scrollPosition
モディファイアは、scrollTargetLayout
モディファイアとセットで使用します。scrollTargetLayout
モディファイアを省略しても、プログラムによる自動スクロールは可能ですが、焦点が当てられているビューの追跡はできません。
scrollPosition
モディファイアの引数anchor
に対し.top
、もしくは.bottom
を設定した場合、ビューの50%が指定された境界を超えるとViewがもつIDがscrolledID
プロパティに代入されます。この時の閾値は任意の比率に変更ができない模様です。
下の例では、パレットのカラーをタップすることで、タップされたカラーをscrolledID
プロパティに代入し、自動スクロールをさせています。
また、スクロール時におけるスクロールビューが焦点を当てているビューの変遷も、onChange
モディファイアを使用して出力しています。
使用したコードはこちら
struct ContentView: View {
@State private var scrolledID: Color? = nil
private let colors: [Color] = [.pink, .blue, .orange, .green, .purple]
var body: some View {
ScrollView {
LazyVStack {
ForEach(colors, id: \.self, content: textCard)
}
.scrollTargetLayout()
}
.scrollPosition(id: $scrolledID, anchor: .center) // ✅
.animation(.default, value: scrolledID)
.overlay(alignment: .bottom, content: buttonPalette)
.onChange(of: scrolledID) {
print(.init(describing: $0) + " → " + .init(describing: $1))
}
}
func textCard(_ color: Color) -> some View {
Text(color.description)
.foregroundStyle(.white)
.textCase(.uppercase)
.font(.largeTitle)
.fontWeight(.semibold)
.frame(width: 350, height: 500)
.background(color.gradient)
}
func buttonPalette() -> some View {
HStack(spacing: 10) {
ForEach(colors, id: \.self) { color in
Button {
scrolledID = color
} label: {
Circle()
.fill(color.gradient)
.frame(width: 50, height: 50)
}
}
}
.padding(.vertical, 15)
.padding(.horizontal, 20)
.background(.background.opacity(0.8))
.clipShape(.capsule)
}
}
ScrollPosition
scrollPosition
モディファイアとScrollPosition
を組み合わせて使用することで、指定のIDをもつビューまでの自動スクロール、スクロールビューが表示された時のスクロール位置の指定、焦点が当てられているビューの追跡に加え、指定のオフセットまでの自動スクロール、指定のエッジまでの自動スクロールを可能とします。
scrollTo
メソッドを使用することで、指定のIDをもつビューまでの自動スクロール、指定のオフセットまでの自動スクロール、指定のエッジまでの自動スクロールが可能です。
viewID
プロパティで焦点が当てられているビューのIDの確認が可能です。
下の例では、パレットのカラーをタップすることで、タップされたカラーをscrollTo
メソッドの引数id
に渡し自動スクロールをさせており、Back to Top ボタンをタップすることで、.top
をscrollTo
メソッドの引数edge
に渡し自動スクロールをさせています。
また、スクロール時におけるスクロールビューが焦点を当てているビューの変遷も、onChange
モディファイアを使用して出力しています。
使用したコードはこちら
struct ContentView: View {
@State private var scrollPosition: ScrollPosition = .init(idType: Color.self) // ✅
private let colors: [Color] = [.pink, .blue, .orange, .green, .purple]
var body: some View {
ScrollView {
LazyVStack {
ForEach(colors, id: \.self, content: textCard)
}
.scrollTargetLayout()
}
.scrollPosition($scrollPosition, anchor: .center)
.animation(.default, value: scrollPosition)
.overlay(alignment: .bottom, content: buttonPalette)
.onChange(of: scrollPosition) {
print(scrollPosition.viewID as Any)
}
}
func textCard(_ color: Color) -> some View {
Text(color.description)
.foregroundStyle(.white)
.textCase(.uppercase)
.font(.largeTitle)
.fontWeight(.semibold)
.frame(width: 350, height: 500)
.background(color.gradient)
}
func buttonPalette() -> some View {
VStack(spacing: nil) {
HStack(spacing: 10) {
ForEach(colors, id: \.self) { color in
Button {
scrollPosition.scrollTo(id: color)
} label: {
Circle()
.fill(color.gradient)
.frame(width: 50, height: 50)
}
}
}
Button("Back to Top") {
scrollPosition.scrollTo(edge: .top)
}
.fontWeight(.semibold)
}
.padding()
.background(.background.opacity(0.8))
.clipShape(.rect(cornerRadius: 10))
}
}
ScrollViewReader
ScrollViewReader
を使用することで、指定のIDをもつビューまでの自動スクロールを可能とします。
言ってしまえば、ScrollViewReader
は、ScrollViewProxy
インスタンスを提供するビュービルダーです。
ScrollViewProxy
インスタンスのscrollTo(_:anchor:)
メソッドを使用することで、指定したIDのビューまで自動スクロールさせることができます。
※ ScrollView
の先頭や末尾、特定のオフセットなどに自動スクロールするといったことは、ScrollViewReader
ではできません。
下の例では、ScrollViewProxy
インスタンスのscrollTo(_:anchor:)
メソッドを使用して、IDが10に設定されているタイルまで自動スクロールをさせています。
引数anchor
には、それぞれ.center
、.top
、.bottom
が設定されています。
.center | .top | .bottom |
---|---|---|
![]() |
![]() |
![]() |
使用したコードはこちら
struct ContentView: View {
private let colors: [Color] = [.red, .blue, .orange, .green]
@State private var anchor: UnitPoint = .center
var body: some View {
ScrollViewReader { scrollViewProxy in // ✅
ScrollView {
LazyVStack {
ForEach(0..<30, id: \.self, content: textTile)
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
VStack(spacing: 5) {
jumpButton(scrollViewProxy)
anchorPicker
}
.background(.black)
}
}
.font(.title.bold())
}
func textTile(_ i: Int) -> some View {
Text("Example \(i)")
.foregroundStyle(.white)
.frame(width: 200, height: 200)
.background(colors[i % colors.count].gradient)
}
func jumpButton(_ scrollViewProxy: ScrollViewProxy) -> some View {
Button("Jump to Example 10") {
withAnimation {
scrollViewProxy.scrollTo(10, anchor: anchor)
}
}
.padding(.top)
.frame(maxWidth: .infinity)
}
var anchorPicker: some View {
let selectionContent: [(String, UnitPoint)] = [
("Top", .top), ("Center", .center), ("Bottom", .bottom)
]
return Picker("Anchor", selection: $anchor) {
ForEach(selectionContent, id: \.1) { content in
Text(content.0)
}
}
}
}
scrollInputBehavior
scrollInputBehavior
を使用することで、特定の入力による自動スクロールの有効化、または無効化を設定することができます。
実際のところ、本記事の執筆時点で設定可能な入力は、watchOSで用いられる.handGestureShortcut
だけの模様です。
struct ContentView: View {
var body: some View {
ScrollView {
// content
}
.scrollInputBehavior(.disabled, for: .handGestureShortcut) // ✅
}
}
toolbarBackgroundVisibility
toolbarBackgroundVisibility
モディファイアを使用することで、特定のツールバーの背景を非表示に設定することができます。
※ ナビゲーションタイトルが設定されている場合、ビューと重なってしまうので注意が必要です。
.toolbarBackgroundVisibility(.hidden, for: .navigationBar)なし | .toolbarBackgroundVisibility(.hidden, for: .navigationBar)あり |
---|---|
![]() |
![]() |
使用したコードはこちら
struct ContentView: View {
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: nil) {
ForEach(10000..<10100) { i in
Text(i.description)
}
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("action", action: {})
}
}
.toolbarBackgroundVisibility(.hidden, for: .navigationBar) // ✅
}
}
}
Samples(scrollTargetBehavior)
Sample1 | Sample2 |
---|---|
![]() |
![]() |
使用したコードはこちら(Sample1)
struct ContentView: View {
private let photographers = ["Frank Wilson", "Hank Anderson", "Ivy Thomas", "Grace Taylor", "Eve Davis", "Charlie Brown", "David Miller", "Alice Smith", "Jack Moore", "Bob Johnson"]
private let imageURL = URL(string: "https://picsum.photos/1920/1080")
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 16) {
ForEach(photographers, id: \.self) { photographer in
imageSlide(photographer)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.aspectRatio(16/9, contentMode: .fit)
.containerRelativeFrame(.horizontal)
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.scrollClipDisabled()
.contentMargins(.horizontal, 28, for: .scrollContent)
.scrollIndicators(.hidden)
}
func imageSlide(_ photographer: String) -> some View {
AsyncImage(url: imageURL) { imagePhase in
switch imagePhase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.scaledToFit()
.clipShape(.rect(cornerRadius: 16))
.shadow(radius: 4)
.overlay(alignment: .bottomTrailing) {
Text(photographer)
.foregroundStyle(.white)
.font(.custom("Snell Roundhand", size: 24))
.fontWeight(.black)
.padding(.trailing, 16)
.padding(.bottom, 8)
}
case .failure(let error):
Text(error.localizedDescription)
@unknown default:
fatalError()
}
}
}
}
使用したコードはこちら(Sample2)
struct ContentView: View {
struct ProgrammingLanguage {
let name: String
let description: String
}
private let programmingLanguageList: [ProgrammingLanguage] = [
.init(name: "Swift", description: "A modern, safe, and fast language developed by Apple for iOS and macOS apps."),
.init(name: "Java", description: "A robust object-oriented language widely used for enterprise software."),
.init(name: "PHP", description: "A server-side scripting language designed for web development and dynamic sites."),
.init(name: "C#", description: "A modern object-oriented language from Microsoft, popular for Windows applications."),
.init(name: "TypeScript", description: "A statically typed superset of JavaScript that compiles to plain JavaScript."),
.init(name: "Ruby", description: "An elegant, dynamic language known for its simplicity and high productivity."),
.init(name: "JavaScript", description: "A versatile scripting language primarily used to create interactive web pages."),
.init(name: "Kotlin", description: "A modern, concise language for JVM and Android development with strong type safety."),
.init(name: "Python", description: "A powerful high-level language popular for web development, data science, and automation.")
]
private let imageURL = URL(string: "https://picsum.photos/900/1200")
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 20) {
ForEach(programmingLanguageList, id: \.name) { programmingLanguage in
imageSlide(programmingLanguage)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.aspectRatio(3/4, contentMode: .fit)
.containerRelativeFrame(.horizontal)
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.scrollClipDisabled()
.contentMargins(.horizontal, 32, for: .scrollContent)
.scrollIndicators(.hidden)
}
func imageSlide(_ programmingLanguage: ProgrammingLanguage) -> some View {
AsyncImage(url: imageURL) { imagePhase in
switch imagePhase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.scaledToFit()
.overlay(alignment: .bottom) {
VStack(alignment: .leading, spacing: 4) {
Text(programmingLanguage.name)
.font(.headline)
Text(programmingLanguage.description)
.lineLimit(2)
}
.foregroundStyle(Color.init(red: 0.25, green: 0.25, blue: 0.25))
.frame(maxWidth: .infinity)
.padding(12)
.background(.white.opacity(0.75))
}
.clipShape(.rect(cornerRadius: 16))
.shadow(radius: 4)
case .failure(let error):
Text(error.localizedDescription)
@unknown default:
fatalError()
}
}
}
}
EnvironmentValues
@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
public var isScrollEnabled: Bool
@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
public var horizontalScrollIndicatorVisibility: ScrollIndicatorVisibility
@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
public var verticalScrollIndicatorVisibility: ScrollIndicatorVisibility
@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
@available(visionOS, unavailable)
public var scrollDismissesKeyboardMode: ScrollDismissesKeyboardMode
@available(iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9.4, *)
public var horizontalScrollBounceBehavior: ScrollBounceBehavior
@available(iOS 16.4, macOS 13.3, tvOS 16.4, watchOS 9.4, *)
public var verticalScrollBounceBehavior: ScrollBounceBehavior
Discussion