iOS13(SwiftUI 1.0)のScrollViewを使いこなす

# 概要

iOS13(SwiftUI 1.0)のScrollViewは「offsetの変更検知」や「スクロールポジションの変更」をするAPIをもっていないのですが、いろいろなテクニックを駆使することで実現することは可能です。
今回はその方法を紹介します。

完全なソースコードはgithubに上げています。
https://github.com/k-yamada/swiftui-scrollview-ios13

# 作るもの

以下の機能を持ったViewを実装します。

- スクロールビューのoffsetの変更検知
- スクロールポジションの変更

<img src="https://raw.githubusercontent.com/k-yamada/swiftui-scrollview-ios13/main/demo.gif" width="400">

この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](https://medium.com/@maxnatchanon/swiftui-how-to-get-content-offset-from-scrollview-5ce1f84603ec)

```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
        }
    }
}
```