概要
iOS13(SwiftUI 1.0)のScrollViewは「offsetの変更検知」や「スクロールポジションの変更」をするAPIをもっていないのですが、いろいろなテクニックを駆使することで実現することは可能です。
今回はその方法を紹介します。
完全なソースコードはgithubに上げています。
https://github.com/k-yamada/swiftui-scrollview-ios13
作るもの
以下の機能を持ったViewを実装します。
- スクロールビューのoffsetの変更検知
- スクロールポジションの変更
このViewのソースコードは以下の通りです。
ContentView.swift
import SwiftUI
struct ContentView: View {
private let scrollingProxy = ScrollingProxy() // proxy helper
@State private var scrollViewContentOffset = CGFloat(0)
@State private var scrollViewContentMaxOffset = CGFloat(0)
var body: some View {
VStack(spacing: 0) {
HStack {
Button(action: {
self.scrollingProxy.scrollTo(.top, animated: true)
}) {
Image(systemName: "arrow.up.to.line")
.padding(.horizontal)
}
Button(action: {
self.scrollingProxy.scrollTo(.end, animated: true)
}) {
Image(systemName: "arrow.down.to.line")
.padding(.horizontal)
}
Button(action: {
self.scrollingProxy.scrollTo(.point(point: CGPoint(x: 0, y: 1000)), animated: true)
}) {
Text("y=1000")
}
}
Divider()
TrackableScrollView(
.vertical,
showIndicators: true,
contentOffset: $scrollViewContentOffset,
contentMaxOffset: $scrollViewContentMaxOffset,
offsetChanged: { offset in
print("offsetChanged: \(offset)")
},
content: {
ForEach(0 ..< 200) { i in
makeItem(index: i)
.listRowInsets(EdgeInsets())
.background(
ScrollingHelper(proxy: self.scrollingProxy) // injection
)
}
}
)
Divider()
HStack {
Text("offset: \(Int(scrollViewContentOffset)), maxOffset: \(Int(scrollViewContentMaxOffset))")
}
}
}
}
@ViewBuilder private func makeItem(index: Int) -> some View {
VStack(spacing: 0) {
Text("Item \(index)")
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.overlay(Divider(), alignment: .top)
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
スクロールビューのoffsetの変更検知
参考: SwiftUI: How to get content offset from ScrollView
TrackableScrollView.swift
import SwiftUI
// https://medium.com/@maxnatchanon/swiftui-how-to-get-content-offset-from-scrollview-5ce1f84603ec
struct TrackableScrollView<Content>: View where Content: View {
let axes: Axis.Set
let showIndicators: Bool
@Binding var contentOffset: CGFloat
@Binding var contentMaxOffset: CGFloat
let offsetChanged: (Offset) -> Void
let content: Content
var body: some View {
GeometryReader { outsideProxy in
ScrollView(self.axes, showsIndicators: self.showIndicators) {
ZStack(alignment: self.axes == .vertical ? .top : .leading) {
GeometryReader { insideProxy in
Color.clear
.preference(key: ScrollOffsetPreferenceKey.self, value: [
calculateContentOffset(fromOutsideProxy: outsideProxy, insideProxy: insideProxy),
calculateContentMaxOffset(fromOutsideProxy: outsideProxy, insideProxy: insideProxy)
])
}
VStack(spacing: 0) {
self.content
}
}
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
contentOffset = value[0]
contentMaxOffset = value[1]
let offset = Offset(offset: contentOffset, maxOffset: contentMaxOffset)
offsetChanged(offset)
}
}
}
init(
_ axes: Axis.Set = .vertical,
showIndicators: Bool = true,
contentOffset: Binding<CGFloat>,
contentMaxOffset: Binding<CGFloat>,
offsetChanged: @escaping (Offset) -> Void,
@ViewBuilder content: () -> Content
) {
self.axes = axes
self.showIndicators = showIndicators
_contentOffset = contentOffset
_contentMaxOffset = contentMaxOffset
self.offsetChanged = offsetChanged
self.content = content()
}
private func calculateContentOffset(fromOutsideProxy outsideProxy: GeometryProxy, insideProxy: GeometryProxy) -> CGFloat {
if axes == .vertical {
return outsideProxy.frame(in: .global).minY - insideProxy.frame(in: .global).minY
} else {
return outsideProxy.frame(in: .global).minX - insideProxy.frame(in: .global).minX
}
}
private func calculateContentMaxOffset(fromOutsideProxy outsideProxy: GeometryProxy, insideProxy: GeometryProxy) -> CGFloat {
if axes == .vertical {
return insideProxy.frame(in: .global).height - outsideProxy.frame(in: .global).height
} else {
return insideProxy.frame(in: .global).width - outsideProxy.frame(in: .global).width
}
}
}
ScrollOffsetPreferenceKey.swift
import SwiftUI
@available(iOS 13.0, *)
struct ScrollOffsetPreferenceKey: PreferenceKey {
typealias Value = [CGFloat]
static var defaultValue: [CGFloat] = [0]
static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
value.append(contentsOf: nextValue())
}
}
スクロールポジションの変更
参考: https://stackoverflow.com/a/60855853/4791194
ScrollingHelper.swift
import SwiftUI
// https://stackoverflow.com/a/60855853/4791194
struct ScrollingHelper: UIViewRepresentable {
let proxy: ScrollingProxy // reference type
func makeUIView(context: Context) -> UIView {
UIView() // managed by SwiftUI, no overloads
}
func updateUIView(_ uiView: UIView, context: Context) {
// この時点ではスクロールビューが取得できないことがあるので、遅延実行する
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
proxy.catchScrollView(for: uiView) // here UIView is in view hierarchy
}
}
}
ScrollingProxy.swift
import SwiftUI
// https://stackoverflow.com/a/60855853/4791194
class ScrollingProxy {
enum Action {
case end
case top
case point(point: CGPoint)
}
private var scrollView: UIScrollView?
func catchScrollView(for view: UIView) {
if scrollView == nil {
scrollView = view.enclosingScrollView()
}
}
func scrollTo(_ action: Action, animated: Bool) {
if let scroller = scrollView {
var rect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1))
switch action {
case .end:
rect.origin.y = scroller.contentSize.height +
scroller.contentInset.bottom + scroller.contentInset.top - 1
case let .point(point):
rect.origin.y = point.y
default: {
// default goes to top
}()
}
scroller.scrollRectToVisible(rect, animated: animated)
}
}
}
extension UIView {
func enclosingScrollView() -> UIScrollView? {
var next: UIView? = self
repeat {
next = next?.superview
if let scrollview = next as? UIScrollView {
return scrollview
}
} while next != nil
return nil
}
}
OffsetType.swift
enum OffsetType {
case top
case middle
case bottom
}
Offset.swift
import SwiftUI
struct Offset {
let offset: CGFloat
let maxOffset: CGFloat
var type: OffsetType {
if offset == 0 {
return .top
} else if offset == maxOffset {
return .bottom
} else {
return .middle
}
}
}