SwiftUI で ScrollView を自作する色々
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 を公開してくれたら良いんだが。
ScrollViewを書き直してUIScrollView.contentOffsetをBindingで操作できるようにした実装。
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()
}
}
メインループ(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を使うべき。
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
計測したあとで「先に調べてる人おるやろ」と思ってググったらでてきたものがこの記事
コードはこちら
CADisplayLink を知った。
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 についてちゃんと調べないといけない。
↑をリファクタリングする。
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 を整理できそう。
そろそろカロリー切れてきたので補充する。
プレビューが面倒なのでグラフを書くようなものを作る。
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())
}
}
UIScrollViewで、コンテンツをスクロールする→端にぶつかって跳ね返る、部分のアニメーションを作る。コンテンツをスクロールする場合の処理が UIScrollView.DecelerationRate だとして、その最終着地点と反射点を比較し、ロールバックのアニメーションをかぶせる。
このあたりでアニメーションの合成というアイディアを得たので、合成可能なアニメーションを色々と作っていく。試行錯誤して Deceleration+EaseInOutをやってみたけど、反射のアニメーションがすごく遅い(それはそう)
やはり Spring にしないとだめか……。
UIScrollViewの実際のアニメーションを計測してグラフにしたもの。
どのアニメーションでも DecelerationRate だけが使われていそうに見える。
Content Frame の外にドラッグして指を離した場合、DecelerationRate.fast にそって減速していくっぽいが、初速が謎だった。どうやら目標への距離×10ほどに等しいらしい。
跳ね返りのアニメーションもDecelerationRate.fastが使われている匂いがするが、普通にやってもうまく行かないはずなのでなにがどうなってるのかわからない。(DecelerationRateは初速を0に近づけていくだけなので)
てかSpringっぽいな。DecelerationRate.fastで変化量が0に漸近したらSpringに切り替える、という動きをしているような気がする。参考にしている記事にもそれっぽいことが書いてある。
- 変化量が0に漸近したらSpringに切り替えるものを書く
- Projectorの最終値を0にスナップさせるものを書く(0にスナップしないProjectorは壊れる)
- ζ=1のSpringProjectorが壊れているので直さなければ……