3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

SwiftAdvent Calendar 2021

Day 11

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

Posted at

概要

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
        }
    }
}
3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?