Closed12

SwiftUI で ScrollView を自作する色々

NiaNia

ScrollView の content offset を計測するやつ

Ref: https://swiftwithmajid.com/2020/09/24/mastering-scrollview-in-swiftui/

↑の書いているものが全てだけど少しいじった。

import SwiftUI

private struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGPoint = .zero

    static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
}

struct ScrollViewCaptureReader: View {
    let name: String

    init(_ name: String) {
        self.name = name
    }

    var body: some View {
        GeometryReader { geometry in
            Color.clear.preference(
                key: ScrollOffsetPreferenceKey.self,
                value: geometry.frame(in: .named(name)).origin
            )
        }
        .frame(width: 0, height: 0)
    }
}

extension ScrollView {

    func capture(_ perform: (CGPoint) -> Void) -> some View {
        let name = "ScrollViewCapture-\(UUID().description)"
        return ScrollView<TupleView<(ScrollViewCaptureReader, Content)>>(axes, showsIndicators: showsIndicators) {
            ScrollViewCaptureReader(name)
            content
        }
        .coordinateSpace(name: name)
        .onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: perform)
    }
}

How to use

struct SampleView: View {
    var body: some View {
        ScrollView(.vertical, showsIndicators: false) {
            Text("sample")
        }
        .capture { print($0) }
    }
}

Note

  • .capture は ScrollView に対してしか使えない。一番最初に書かなければならない。地味に面倒そう。
  • ScrollView が Binding で Content Offset を公開してくれたら良いんだが。
NiaNia

memo: 見る価値はない

import SwiftUI

struct ContentView: View {
    var body: some View {
        TabView {
            PageView(header: HeaderView()) {
                Text("Page 1")
                    .frame(height: 1000)
                    .pageTitle("Page 1")
                    .onAppear {
                        print("Page 1", "onAppear")
                    }
                    .onDisappear {
                        print("Page 1", "onDisappear")
                    }
                Text("Page 2")
                    .frame(height: 1000)
                    .onAppear {
                        print("Page 2", "onAppear")
                    }
                    .onDisappear {
                        print("Page 2", "onDisappear")
                    }
                    .pageTitle("Page 2")
                Text("Page 3")
                    .frame(height: 1000)
                    .onAppear {
                        print("Page 3", "onAppear")
                    }
                    .onDisappear {
                        print("Page 3", "onDisappear")
                    }
                    .pageTitle("Page 3")
            }
                .tabItem { Text("Page View") }
                .onAppear {
                    print("PageView", "onAppear")
                }
                .onDisappear {
                    print("PageView", "onDisappear")
                }
            Text("Config")
                .tabItem { Text("Config") }
                .onAppear {
                    print("Config", "onAppear")
                }
                .onDisappear {
                    print("Config", "onDisappear")
                }
        }
        .edgesIgnoringSafeArea(.all)
    }
}

struct HeaderView: View {
    var body: some View {
        VStack {
            Text("Header")
                .padding()
        }
    }
}

struct PageTitleKey: PreferenceKey {
    static var defaultValue: String = ""
    static func reduce(value: inout String, nextValue: () -> String) {
        value = nextValue()
    }
}

struct PageVisibleKey: EnvironmentKey {
    static var defaultValue: Bool = false
}

extension EnvironmentValues {

    var pageVisible: Bool {
        get { self[PageVisibleKey.self] }
        set { self[PageVisibleKey.self] = newValue }
    }
}

protocol PageTitleHolder {
    var title: String { get }
}

struct PageContainer<Content: View>: View, PageTitleHolder {
    let title: String
    let content: Content

    init(title: String, _ content: @escaping () -> Content) {
        self.title = title
        self.content = content()
    }

    var body: some View {
        content
            .preference(key: PageTitleKey.self, value: title)
    }
}

extension View {

    func pageTitle(_ title: String) -> some View {
        PageContainer(title: title) { self }
    }
}

struct HeaderHeightKey: PreferenceKey {
    static var defaultValue: CGFloat = 0

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

class PageTitleStorage: ObservableObject {
    @Published var data: Dictionary<Int, String> = [:]

    subscript(at index: Int) -> String? {
        data[index]
    }

    func set(at index: Int, _ value: String) {
        objectWillChange.send()
        data[index] = value
    }

    func append(_ value: String) {
        objectWillChange.send()
        data[data.count] = value
    }
}

struct PageView<Header: View>: View {
    @State var oldIndex: CGFloat? = nil
    @State var currentIndexStable: CGFloat = 0
    @State var currentIndexDelta: CGFloat = 0
    @State var headerHeight: CGFloat = 0
    @State var currentOffset: CGPoint = .zero

    @ObservedObject var pageTitles = PageTitleStorage()

    var header: Header
    var currentIndex: CGFloat { currentIndexStable + currentIndexDelta }
    var pages: Array<AnyView> = []

    public init<V1: View, V2: View>(header: Header, @ViewBuilder content: () -> TupleView<(V1, V2)>) {
        let views = content().value
        self.header = header
        self.pages = [
            AnyView(views.0),
            AnyView(views.1),
        ]

        captureTitle(at: 0, view: views.0)
        captureTitle(at: 1, view: views.1)
    }

    public init<V1: View, V2: View, V3: View>(header: Header, @ViewBuilder content: () -> TupleView<(V1, V2, V3)>) {
        let views = content().value
        self.header = header
        self.pages = [
            AnyView(views.0),
            AnyView(views.1),
            AnyView(views.2),
        ]

        captureTitle(at: 0, view: views.0)
        captureTitle(at: 1, view: views.1)
        captureTitle(at: 2, view: views.2)
    }

    private func captureTitle<Content: View>(at index: Int, view: Content) {
        let rect = CGRect(origin: .zero, size: CGSize(width: 100, height: 100))
        let rootView = view
            .onPreferenceChange(PageTitleKey.self) { [weak pageTitles] in pageTitles?.set(at: index, $0) }
        UIHostingController(rootView: rootView)
            .view.drawHierarchy(in: rect, afterScreenUpdates: true)
    }

    let spacing: CGFloat = 8

    func isShow(at index: Int) -> Bool {
        if let oldIndex = oldIndex, oldIndex.rounded(.down) == CGFloat(index) || oldIndex.rounded(.up) == CGFloat(index) {
            return true
        }
        return currentIndex.rounded(.down) == CGFloat(index) || currentIndex.rounded(.up) == CGFloat(index)
    }

    var body: some View {
        GeometryReader { geometry in
            ZStack(alignment: .topLeading) {
                HStack(spacing: spacing) {
                    ForEach(0..<pages.count) { index in
                        Group {
                            if isShow(at: index) {
                                ScrollableView($currentOffset, showsIndicators: false, axis: .vertical) {
                                    pages[index]
                                        .frame(width: geometry.size.width)
                                        .padding(.top, headerHeight + 46)
                                        .onPreferenceChange(PageTitleKey.self) {
                                            pageTitles.set(at: index, $0)
                                        }
                                }
                            } else {
                                Color.clear
                            }
                        }
                            .frame(width: geometry.size.width, height: geometry.size.height)
                            .background(Color(UIColor.systemBackground))
                    }
                }
                .offset(x: -currentIndex * (geometry.size.width + spacing), y: 0)
                header
                    .frame(width: geometry.size.width)
                    .background(
                        GeometryReader { geo in
                            Color(UIColor.systemBackground)
                                .preference(key: HeaderHeightKey.self, value: geo.size.height)
                                .onPreferenceChange(HeaderHeightKey.self) { headerHeight = $0 }
                        }
                    )
                VStack(alignment: .leading, spacing: 0) {
                    HStack {
                        ForEach(0..<pages.count) { index in
                            Text(pageTitles[at: index] ?? "")
                                .font(currentIndex == CGFloat(index) ? .body.bold() : .body)
                                .frame(width: geometry.size.width / CGFloat(pages.count), height: 44)
                        }
                    }
                    Rectangle()
                        .foregroundColor(.accentColor)
                        .frame(width: geometry.size.width / CGFloat(pages.count), height: 2)
                        .offset(x: currentIndex * geometry.size.width / CGFloat(pages.count), y: 0)
                }
                    .frame(width: geometry.size.width, height: 46)
                    .background(Color(UIColor.systemBackground))
                    .padding(.top, headerHeight)
            }
            .gesture(
                DragGesture(minimumDistance: 20)
                    .onChanged { action in
                        currentIndexDelta = -action.translation.width / geometry.size.width
                        if currentIndex < 0 {
                            currentIndexDelta /= 2
                        }
                        if CGFloat(pages.count - 1) < currentIndex {
                            currentIndexDelta /= 2
                        }
                    }
                    .onEnded { action in
                        let delta = -(action.translation.width + action.predictedEndTranslation.width) / geometry.size.width
                        oldIndex = currentIndex
                        withAnimation(.easeOut(duration: 0.25)) {
                            currentIndexStable = max(min((currentIndexStable + delta).rounded(), currentIndexStable + 1), currentIndexStable - 1)
                            currentIndexDelta = 0
                            if currentIndexStable < 0 {
                                currentIndexStable = 0
                            }
                            if CGFloat(pages.count) <= currentIndexStable {
                                currentIndexStable = CGFloat(pages.count - 1)
                            }
                        }
                        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
                            oldIndex = nil
                        }
                    }
            )
        }
        .onChange(of: currentIndexStable, perform: { value in
            print("currentIndexStable", value)
        })
        .onChange(of: currentOffset, perform: { value in
            print("currentOffset", value)
        })
        .background(Color(UIColor.secondarySystemBackground))
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()

    }
}
NiaNia

メインループ(DispatchQueue.main.async)で数値をアニメーションするテスト

import SwiftUI

struct ContentView: View {
    @ObservedObject var animator = ValueAnimator()

    var body: some View {
        VStack {
            Button(action: { animator.isEnabled.toggle() }) {
                Text(animator.isEnabled ? "Stop" : "Start")
            }
            Text(((animator.value * 1000).rounded() / 1000).description)
                .padding()
        }
    }
}

class ValueAnimator: ObservableObject {

    @Published var value: TimeInterval = 0
    @Published var isEnabled = false {
        didSet {
            if isEnabled { startAt = Date() }
        }
    }

    var startAt: Date = Date()
    var timer: Timer! = nil
    let lock = NSLock()

    init () {
        timer = Timer.scheduledTimer(withTimeInterval: 0.016, repeats: true) { [weak self] _ in // 60 fps
            guard self?.isEnabled == true else { return }
            self?.update()
        }
    }

    deinit {
        timer.invalidate()
    }

    func start() {
        guard lock.try() else { return }
        defer { lock.unlock() }

        value = 0
        startAt = Date()
    }

    func update() {
        guard lock.try() else { return }
        defer { lock.unlock() }

        self.value = Date().timeIntervalSince(self.startAt)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

↓で書いてるが、CADisplayLinkを使うべき。

NiaNia

UIScrollViewの慣性スクロールのアニメーション時間を計測する簡易的なやつ

import UIKit

class ViewController: UIViewController, UIScrollViewDelegate {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }

    var startAt = Date()
    var endAt = Date()
    var startOffset: CGFloat = 0
    var endOffset: CGFloat = 0

    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        // print("scrollViewWillBeginDragging")
    }

    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        // print("scrollViewDidEndDragging")
        startAt = Date()
        startOffset = scrollView.contentOffset.y
    }

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let at = Date()
        endAt = at
        endOffset = scrollView.contentOffset.y
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [self] in
            guard self.endAt == at, self.startAt < self.endAt else { return }
            print(self.endAt.timeIntervalSince(self.startAt), abs(endOffset - startOffset))
        }
    }
}
2.8603819608688354 1552.0
2.2432949542999268 439.9999999999998
1.4655280113220215 90.33333333333348
1.6032949686050415 116.0
1.705935001373291 144.33333333333303

計測したあとで「先に調べてる人おるやろ」と思ってググったらでてきたものがこの記事
https://medium.com/@esskeetit/how-uiscrollview-works-e418adc47060

コードはこちら
https://github.com/super-ultra/ScrollMechanics

CADisplayLink を知った。
https://developer.apple.com/documentation/quartzcore/cadisplaylink

NiaNia

SwiftUIで捏造したScrollViewもどき(縦方向のスクロールのみ

import SwiftUI

struct PageContainerParameters {
    var headerHeight: CGFloat
    var dragMinimumDistance: CGFloat

    static let defaultParams = PageContainerParameters(
        headerHeight: 0,
        dragMinimumDistance: 10
    )
}

private struct PageFrameSizeKey: PreferenceKey {
    static var defaultValue: CGSize = .zero

    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        value = nextValue()
    }

    typealias Value = CGSize
}

private struct PageContentSizeKey: PreferenceKey {
    static var defaultValue: CGSize = .zero

    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        value = nextValue()
    }

    typealias Value = CGSize
}

struct PageContainerView: View {

    let params: PageContainerParameters
    @State var offset: CGPoint = .zero
    @State var gestureOffset: CGPoint = .zero
    @State var lastDragValue: DragGesture.Value!
    @State var contentSize: CGSize = .zero
    @State var frameSize: CGSize = .zero

    init(_ params: PageContainerParameters? = nil) {
        self.params = params ?? PageContainerParameters.defaultParams
    }

    var body: some View {
        GeometryReader { geometry in
            VStack(spacing: 16) {
                ForEach(0..<100) { index in
                    Text("Index \(index)")
                        .padding()
                        .background(Color(UIColor.secondarySystemBackground))
                        .cornerRadius(3)
                }
            }
            .padding()
            .background(GeometryReader { geometry in
                Color.clear
                    .preference(key: PageContentSizeKey.self, value: geometry.size)
                    .onPreferenceChange(PageContentSizeKey.self) { contentSize = $0 }
            })
            .offset(actualOffset)
            Color.clear.preference(key: PageFrameSizeKey.self, value: geometry.size)
        }
        .onPreferenceChange(PageFrameSizeKey.self) { frameSize = $0 }
        .border(Color.red, width: 1)
        .gesture(
            DragGesture(minimumDistance: params.dragMinimumDistance)
                .onChanged { value in
                    TimerAnimation.current?.invalidate()
                    gestureOffset.y = -value.translation.height
                    lastDragValue = value
                }
                .onEnded { value in
                    let decelerationRate: CGFloat = UIScrollView.DecelerationRate.normal.rawValue
                    let velocity = (value.translation - value.predictedEndTranslation).height * log(decelerationRate)

                    gestureOffset = .zero
                    offset = CGPoint(x: 0, y: offset.y - value.translation.height)

                    if offset.y <= 0 {
                        return topBouse(-velocity * 2)
                    }

                    if contentSize.height - frameSize.height <= offset.y {
                        return bottomBouse(-velocity * 2)
                    }

                    let parameters = DecelerationTimingParameters(
                        initialValue: offset,
                        initialVelocity: CGPoint(x: 0, y: velocity * -1000),
                        decelerationRate: decelerationRate,
                        threshold: 0.5 / UIScreen.main.scale)

                    if parameters.duration > 0.1, abs((value.predictedEndTranslation - value.translation).height) > 50 {
                        TimerAnimation.current = TimerAnimation(duration: parameters.duration) { progress, duration in
                            if offset.y <= 0 {
                                let velocity = max(parameters.velocity(at: duration).y, -frameSize.height / 2)
                                topBouse(velocity * 2)
                            }

                            if contentSize.height - frameSize.height <= offset.y {
                                let velocity = min(parameters.velocity(at: duration).y, frameSize.height / 2)
                                bottomBouse(-velocity * 2)
                            }

                            offset.y = parameters.value(at: duration).y
                        }
                    }
                }
        )
    }

    var actualOffset: CGSize {
        let value = (offset + gestureOffset).y

        if value <= 0 {
            return CGSize(width: 0, height: -value / 2)
        }

        if contentSize.height - frameSize.height <= value {
            return CGSize(width: 0, height: -(contentSize.height - frameSize.height + value) / 2)
        }

        return CGSize(width: 0, height: -value)
    }

    func springAnimation(initialVelocity: CGFloat) -> Animation {
        .interpolatingSpring(mass: 1, stiffness: 100, damping: 20, initialVelocity: Double(initialVelocity))
    }

    func topBouse(_ velocity: CGFloat) {
        withAnimation(springAnimation(initialVelocity: velocity)) {
            offset = .zero
        }
        TimerAnimation.current?.invalidate()
    }

    func bottomBouse(_ velocity: CGFloat) {
        withAnimation(springAnimation(initialVelocity: velocity)) {
            offset = CGPoint(x: 0, y: contentSize.height - frameSize.height)
        }
        TimerAnimation.current?.invalidate()
    }
}

struct PageContainerView_Previews: PreviewProvider {
    static var previews: some View {
        PageContainerView()
    }
}

  • 上下の Bouse Animation を .interpolatingSpring で実装しているけどこれはサボり。感触の良い実装になっていない。
  • TimerAnimation.current を使ってアニメーションを保存しているのもサボり。
  • GeometryReader のパラメータを PreferenceKey で渡してるの見ると「そりゃそうやればできるけどさあ」って気持ちになる。子Viewのサイズとか本来親が知れるものなのでは????SwiftUIにおいてはそうでもないのかな。bodyが参照されるタイミング != レンダリングなので、ということだろうか。GeometryReader についてちゃんと調べないといけない。
NiaNia

↑をリファクタリングする。

contentSize / frameSize の取得処理を簡略に記述する

やってもやらなくてもどっちでもいい。

import SwiftUI

struct CaptureSizeModifier<Key: PreferenceKey>: ViewModifier where Key.Value == CGSize {

    let perform: (Key.Value) -> Void

    init(_ perform: @escaping (CGSize) -> Void) {
        self.perform = perform
    }

    var reader: some View {
        GeometryReader { geometry in
            Color.clear
                .preference(key: Key.self, value: geometry.size)
                .onPreferenceChange(Key.self, perform: perform)
        }
    }

    func body(content: Content) -> some View {
        content.background(reader)
    }
}

extension View {

    func captureSize<Key: PreferenceKey>(_ key: Key.Type, perform: @escaping (CGSize) -> Void) -> some View where Key.Value == CGSize {
        modifier(CaptureSizeModifier<Key>(perform))
    }
}

こういうものを定義することで

            .background(GeometryReader { geometry in
                Color.clear
                    .preference(key: PageContentSizeKey.self, value: geometry.size)
                    .onPreferenceChange(PageContentSizeKey.self) { contentSize = $0 }
            })

これを

            .captureSize(PageContentSizeKey.self) { contentSize = $0 }

このように書ける。

preferenceは上書きされるので、同じものを使っても混線することはない。PageContentSizeKey / PageFrameSizeKey を分けずに使ってもこの場合は動作する。したがって、以下のようにすることができる。

struct CaptureSizeModifier: ViewModifier {

    private struct CaptureSizeModifierKey: PreferenceKey {
        static var defaultValue: CGSize = .zero
        static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
            value = nextValue()
        }
    }

    let perform: (CGSize) -> Void

    init(_ perform: @escaping (CGSize) -> Void) {
        self.perform = perform
    }

    var reader: some View {
        GeometryReader { geometry in
            Color.clear
                .preference(key: CaptureSizeModifierKey.self, value: geometry.size)
                .onPreferenceChange(CaptureSizeModifierKey.self, perform: perform)
        }
    }

    func body(content: Content) -> some View {
        content.background(reader)
    }
}

extension View {

    func captureSize(perform: @escaping (CGSize) -> Void) -> some View {
        modifier(CaptureSizeModifier(perform))
    }
}

contentSize / frameSize の整理

  • contentSize / frameSize は頻繁に変わらない+ほかの計算の基準になるので、取り出してまとめたほうが整理されそう。さらに、縦方向のスクロールのみを実装しているので、今回に限っては height のみで処理できそう。

offsetの最大値(下方向の衝突が発生する数値)は contentSize / frameSize によって決まる値で、最小値(上方向の衝突が発生する数値)は 0 なので、どちらもレイアウトによって決まると言える。各所で都度計算しているので、computed property として参照できるようにしておく(名前もつけられる)

offset が正常な表示範囲の外にあるかどうか、外にあるとして上側か下側か、というのも意味のある情報なので、enum で整理する。

レイアウト情報とそこから定まるパラメータをひとまとめにした ObservableObject を作る。

private class VerticalScrollableLayoutModel: ObservableObject {

    @Published var content: CGFloat = .zero
    @Published var frame: CGFloat = .zero

    let minOffset: CGFloat = .zero
    var maxOffset: CGFloat { content - frame }

    // outOfTop <minOffset> inContent <maxOffset> outOfBottom
    enum OffsetPosition {
        case outOfTop, outOfBottom, inContent
    }

    func checkPosition(target currentOffset: CGFloat) -> OffsetPosition {
        if currentOffset <= minOffset {
            return .outOfTop
        }
        if maxOffset <= currentOffset {
            return .outOfBottom
        }
        return .inContent
    }
}

private extension CGFloat {

    func position(in layout: VerticalScrollableLayoutModel) -> VerticalScrollableLayoutModel.OffsetPosition {
        layout.checkPosition(target: self)
    }
}

VerticalScrollableLayoutModel を ViewModifier にして .captureSize もここに入れてしまおうと思ったが、(そうすると contentSize / frameSize を探す部分も自己の責任にできる)ViewModifier は struct でなければならないらしい。

これによって offset.y <= 0 とかを offset.y.position(in: layout) == .outOfTop とかにできるし、大抵は全パターンの処理が存在するので if ではなく switch で記述できるようになる。

TimingAnimation のリファクタリングと合成と SpringTimingParameters の実装

DragGesture の onEnd 内部でやってるいろいろな計算は、要するに DragGesture.Value を TimingAnimation に変換する処理なので、取り出すことができそう。

  • .interpolatingSpring をやめてちゃんと実装する。(SpringTimingParameters)
  • TimingAnimation の Animations を (_ progress: Double, _ time: TimeInterval) -> Void ではなく (_ time: TimeInterval, _ invalidate: () -> Void) -> Void にしたい。内部から既存のアニメーションを中断する、というアクションが取れるようにする。
  • TimingAnimation.current をやめる。State で VerticalScrollableModifier に保存すればいけるんちゃう?(要テスト

DecelerationTimingParameters と SpringTimingParameters を合成して TimingAnimation を製造できるようにしたい。TimingParameters は要するに (TimeInterval) -> CGFloat が本体なので、参考記事を見ながらいい感じに実装する。

加えて、UIKit だと sender.velocity(in: self) が使えるが SwiftUI だとなぜか使えないので様々な諸々を魔法の数値で調整しているのを再検討する。

Deceleration: https://developer.apple.com/videos/play/wwdc2018/803/?time=2799

WWDCの動画、YouTubeに上げてほしい。2倍速再生したい。ていうか数年前にUIScrollViewのアニメーションの詳細がドキュメント読んでもわからなさすぎておこになってたんだけど、2018年に参考例が出てたんですね。もういいからUIScrollViewのコード公開してくれ。

DragGesture を見やすくする

とりあえずbody内部にいるDragGestureを var drag: some Gesture に移す。

DragGestureの内部を整理する。

  • layout (contentSize / frameSize) = 外部から与えられるパラメータ / この DragGesture がはたらく環境の情報
  • offset / gestureOffset = 状態
  • actualOffset = offset と gestureOffset から得られる値(layoutと作用しあう)
  • DecelerationTimingParameters = 計算の分割
  • .interpolatingSpring = 計算のサボり

また、パッと見で直せそうなところは以下の通り。

  • CGSizeやCGPointを使ってるが、今回は縦方向(Vertical)のスクロールにのみ対応するので、CGFloatで統一できそう。
  • offset / gestureOffset / actualOffset を整理できそう。
NiaNia

そろそろカロリー切れてきたので補充する。

NiaNia

プレビューが面倒なのでグラフを書くようなものを作る。


import SwiftUI

///
/// The Projector maps TimeInterval to Value for UI animation.
///
public protocol Projector {
    /// The velocity of value at time (pixel/sec)
    func velocity(at: TimeInterval) -> CGFloat

    /// The value at time (diff from initial value)
    func value(at: TimeInterval) -> CGFloat

    /// If animation is finished, returns true.
    func isFinished(at: TimeInterval) -> Bool
}

protocol PreviewableProjector {
    var totalDuration: TimeInterval { get }
}

struct ProjectionView: View {
    let projector: Projector & PreviewableProjector
    @State var steps = 100
    @State var value: Double = 0

    var time: TimeInterval {
        projector.totalDuration * value
    }

    var body: some View {
        ScrollView {
            VStack {
                GeometryReader { geometry in
                    ZStack {
                        Path { path in
                            let size = geometry.size.width
                            let duration = projector.totalDuration
                            let lastValue = projector.value(at: duration)
                            path.move(to: CGPoint(x: 0, y: size))
                            (0..<steps).forEach { index in
                                let progress: TimeInterval = TimeInterval(index) / TimeInterval(steps - 1)
                                let time: TimeInterval = duration * progress
                                path.addLine(
                                    to: CGPoint(
                                        x: size * CGFloat(progress),
                                        y: size - size * (projector.value(at: time) / lastValue)
                                    )
                                )
                            }
                        }
                        .stroke(Color.accentColor, style: StrokeStyle(lineWidth: 4.0, lineCap: .round, lineJoin: .round))
                        .frame(width: geometry.size.width, height: geometry.size.width)
                        .padding(8)
                        .background(Color.systemBackground)
                        Path { path in
                            let size = geometry.size.width
                            // Render value preview
                            path.move(to: CGPoint(x: size * CGFloat(value), y: size))
                            path.addLine(
                                to: CGPoint(
                                    x: size * CGFloat(value),
                                    y: 0
                                )
                            )
                        }
                        .stroke(Color.accentColor, style: StrokeStyle(lineWidth: 2.0, lineCap: .round, lineJoin: .round, miterLimit: 0, dash: [8.0], dashPhase: 0.0))
                        .frame(width: geometry.size.width, height: geometry.size.width)
                        .padding(8)
                    }
                    .frame(width: geometry.size.width, height: geometry.size.height)
                }
                .frame(height: 400)
                .padding(8)
                VStack {
                    Slider(value: $value, in: (0.0)...(1.0))
                        .background(Color.systemBackground)
                    DataView(label: "progress", value: value.readableRounded())
                    DataView(label: "time", value: "\(time.readableRounded()) sec")
                    DataView(label: "value", value: "\(Double(projector.value(at: time)).readableRounded())")
                    Text("Total")
                        .bold()
                        .padding(8)
                    DataView(label: "totalSteps", value: "\(steps)")
                    DataView(label: "totalDuration", value: "\(projector.totalDuration) sec")
                }
                .padding()
                .background(Color.systemBackground)
            }
            .background(Color(UIColor.secondarySystemBackground))
        }
    }
}

private extension Double {
    func readableRounded() -> Self {
        (self * 1000.0).rounded() / 1000.0
    }
}

struct DataView: View {
    let label: String
    let value: CustomStringConvertible

    var body: some View {
        VStack {
            HStack {
                Text(label)
                    .font(.system(size: 14))
                    .bold()
                Spacer()
                Text(value.description)
                    .font(.system(size: 12))
            }
            Divider()
        }
    }
}

struct ProjectionView_Previews: PreviewProvider {

    class LinearProjector: Projector, PreviewableProjector {
        var totalDuration: TimeInterval { 4.0 }
        func velocity(at: TimeInterval) -> CGFloat { 1.0 }
        func value(at: TimeInterval) -> CGFloat { CGFloat(at) }
        func isFinished(at: TimeInterval) -> Bool { at > 4.0 }
    }

    static var previews: some View {
        ProjectionView(projector: LinearProjector())
    }
}

NiaNia

UIScrollViewで、コンテンツをスクロールする→端にぶつかって跳ね返る、部分のアニメーションを作る。コンテンツをスクロールする場合の処理が UIScrollView.DecelerationRate だとして、その最終着地点と反射点を比較し、ロールバックのアニメーションをかぶせる。

このあたりでアニメーションの合成というアイディアを得たので、合成可能なアニメーションを色々と作っていく。試行錯誤して Deceleration+EaseInOutをやってみたけど、反射のアニメーションがすごく遅い(それはそう)

やはり Spring にしないとだめか……。

NiaNia

UIScrollViewの実際のアニメーションを計測してグラフにしたもの。

どのアニメーションでも DecelerationRate だけが使われていそうに見える。

Content Frame の外にドラッグして指を離した場合、DecelerationRate.fast にそって減速していくっぽいが、初速が謎だった。どうやら目標への距離×10ほどに等しいらしい。

跳ね返りのアニメーションもDecelerationRate.fastが使われている匂いがするが、普通にやってもうまく行かないはずなのでなにがどうなってるのかわからない。(DecelerationRateは初速を0に近づけていくだけなので)

NiaNia

てかSpringっぽいな。DecelerationRate.fastで変化量が0に漸近したらSpringに切り替える、という動きをしているような気がする。参考にしている記事にもそれっぽいことが書いてある。

  • 変化量が0に漸近したらSpringに切り替えるものを書く
  • Projectorの最終値を0にスナップさせるものを書く(0にスナップしないProjectorは壊れる)
  • ζ=1のSpringProjectorが壊れているので直さなければ……
このスクラップは2021/08/28にクローズされました