From b2b1ed9905c39d0ef15a9274110ff7309f2f5fcd Mon Sep 17 00:00:00 2001 From: Muukii Date: Thu, 2 Oct 2025 16:20:24 +0900 Subject: [PATCH] WIP --- .../Development.xcodeproj/project.pbxproj | 4 + Development/Development/Hopping.swift | 301 ++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 Development/Development/Hopping.swift diff --git a/Development/Development.xcodeproj/project.pbxproj b/Development/Development.xcodeproj/project.pbxproj index bbee0e1..ef27d16 100644 --- a/Development/Development.xcodeproj/project.pbxproj +++ b/Development/Development.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ 4BEAFA4C2A3CE48800478C59 /* AsyncMultiplexImage in Frameworks */ = {isa = PBXBuildFile; productRef = 4BEAFA4B2A3CE48800478C59 /* AsyncMultiplexImage */; }; 4BEAFA4E2A3CE48800478C59 /* AsyncMultiplexImage-Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = 4BEAFA4D2A3CE48800478C59 /* AsyncMultiplexImage-Nuke */; }; 4BEBA5682D3EC5A200BDE020 /* BookRerender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BEBA5672D3EC59A00BDE020 /* BookRerender.swift */; }; + B059CA352E8E36F900879BA6 /* Hopping.swift in Sources */ = {isa = PBXBuildFile; fileRef = B059CA342E8E36F900879BA6 /* Hopping.swift */; }; B0699F292E8863750098A042 /* SelectableForEach in Frameworks */ = {isa = PBXBuildFile; productRef = B0699F282E8863750098A042 /* SelectableForEach */; }; B0699F2B2E8863790098A042 /* StickyHeader in Frameworks */ = {isa = PBXBuildFile; productRef = B0699F2A2E8863790098A042 /* StickyHeader */; }; B0699F2D2E88637D0098A042 /* RefreshControl in Frameworks */ = {isa = PBXBuildFile; productRef = B0699F2C2E88637D0098A042 /* RefreshControl */; }; @@ -62,6 +63,7 @@ 4BD04C182B2C15E100FE41D9 /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; 4BEAFA482A3CE3B100478C59 /* BookVariadicView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVariadicView.swift; sourceTree = ""; }; 4BEBA5672D3EC59A00BDE020 /* BookRerender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookRerender.swift; sourceTree = ""; }; + B059CA342E8E36F900879BA6 /* Hopping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hopping.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -105,6 +107,7 @@ 4B26A6792A33239500B75FB4 /* Development */ = { isa = PBXGroup; children = ( + B059CA342E8E36F900879BA6 /* Hopping.swift */, 4BEBA5672D3EC59A00BDE020 /* BookRerender.swift */, 4B9223612D3CE6B5007E20CB /* GlobalCounter.swift */, 4B08E0AA2CF5805200B05999 /* BookScrollView.swift */, @@ -235,6 +238,7 @@ 4B08E0AB2CF5805500B05999 /* BookScrollView.swift in Sources */, 4BC34FB12CDB1C0C00D22811 /* BookCollectionView.swift in Sources */, 4BD04C152B2C05C600FE41D9 /* BookPlainCollectionView.swift in Sources */, + B059CA352E8E36F900879BA6 /* Hopping.swift in Sources */, 4B910EBC2A77A2F50079D26D /* BookUIKitBasedFlow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Development/Development/Hopping.swift b/Development/Development/Hopping.swift new file mode 100644 index 0000000..f3ab3fb --- /dev/null +++ b/Development/Development/Hopping.swift @@ -0,0 +1,301 @@ +import CollectionView +import SwiftUI + +@available(iOS 17.0, *) +public struct HoppingLayout: CollectionViewLayoutType { + + private let scrollPosition: Binding? + + public init( + selection: Binding + ) { + self.scrollPosition = selection + } + + public init() where Selection == Never { + self.scrollPosition = nil + } + + public func body(content: Content) -> some View { + let view = ScrollView(.vertical, showsIndicators: false) { + // In iOS17, scrollTargetLayout only works if its content is LazyVStack. + UnaryViewReader(readingContent: content) { children in + LazyVStack(spacing: 16) { + ForEach(children, id: \.id) { child in + child + .containerRelativeFrame(.vertical) + } + } + .scrollTargetLayout() + } + } + .scrollTargetBehavior(.viewAligned) + .safeAreaPadding(.vertical, 32) + + if let scrollPosition { + ScrollViewReader { proxy in + view + .animation( + .smooth, + body: { + $0.scrollPosition(id: scrollPosition) + } + ) + .onAppear { + proxy.scrollTo(scrollPosition.wrappedValue) + } + } + } else { + view + } + } + +} + +// MARK: workaround + +/// https://movingparts.io/variadic-views-in-swiftui +struct UnaryViewReader: View { + + let readingContent: ReadingContent + let content: (_VariadicView_Children) -> Content + + init( + readingContent: ReadingContent, + @ViewBuilder content: @escaping (_VariadicView_Children) -> Content + ) { + self.readingContent = readingContent + self.content = content + } + + // MARK: View + + var body: some View { + _VariadicView.Tree(_UnaryView(content: content)) { + readingContent + } + } + +} + +struct MultiViewReader: View { + + let readingContent: ReadingContent + let content: (_VariadicView_Children) -> Content + + init( + readingContent: ReadingContent, + @ViewBuilder content: @escaping (_VariadicView_Children) -> Content + ) { + self.readingContent = readingContent + self.content = content + } + + // MARK: View + + var body: some View { + _VariadicView.Tree(_MultiView(content: content)) { + readingContent + } + } + +} + +private struct _UnaryView: _VariadicView_UnaryViewRoot { + + let content: (_VariadicView_Children) -> Content + + init(@ViewBuilder content: @escaping (_VariadicView_Children) -> Content) { + self.content = content + } + + @ViewBuilder + func body(children: _VariadicView.Children) -> some View { + content(children) + } +} + +private struct _MultiView: _VariadicView_MultiViewRoot { + + let content: (_VariadicView_Children) -> Content + + init(@ViewBuilder content: @escaping (_VariadicView_Children) -> Content) { + self.content = content + } + + @ViewBuilder + func body(children: _VariadicView.Children) -> some View { + content(children) + } +} + +#Preview("Hopping 1") { + + @Previewable @State var scrollPosition: Int? = 3 + + VStack { + HStack { + Button("One") { + scrollPosition = 1 + } + Button("Two") { + scrollPosition = 2 + } + Button("Three") { + scrollPosition = 3 + } + } + .buttonStyle(.bordered) + + CollectionView( + layout: HoppingLayout(selection: $scrollPosition) + ) { + ForEach(0..<30, id: \.self) { i in + CardItem( + title: "Item \(i)", + color: .purple + ) + .id(i) + } + } + } +} + +#Preview("Hopping 2") { + + @Previewable @State var scrollPosition: Int? = 3 + + VStack { + HStack { + Button("One") { + scrollPosition = 1 + } + Button("Two") { + scrollPosition = 2 + } + Button("Three") { + scrollPosition = 3 + } + } + .buttonStyle(.bordered) + + CollectionView( + layout: HoppingLayout(selection: $scrollPosition) + ) { + ForEach(0..<30, id: \.self) { i in + Text("Item \(i)") + .background(.red.opacity(0.3)) + .id(i) + .id(i) + } + } + } +} + +private struct CardItem: View { + let title: String + let color: Color + + var body: some View { + ZStack { + color + Text(title) + .font(.title) + .fontWeight(.bold) + .foregroundColor(.white) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 24) + } +} + + +public struct Hopping: View { + + private let content: Content + private let scrollPosition: Binding? + + public init( + selection: Binding, + @ViewBuilder content: () -> Content + ) { + self.scrollPosition = selection + self.content = content() + } + + public init( + @ViewBuilder content: () -> Content + ) where Selection == Never { + self.scrollPosition = nil + self.content = content() + } + + public var body: some View { + + +// let view = ScrollView(.vertical, showsIndicators: false) { +// // In iOS17, scrollTargetLayout only works if its content is LazyVStack. +// UnaryViewReader(readingContent: content) { children in +// LazyVStack(spacing: 16) { +// ForEach(children, id: \.id) { child in +// child +// .containerRelativeFrame(.vertical) +// } +// } +// .scrollTargetLayout() +// } +// } +// .scrollTargetBehavior(.viewAligned) +// .safeAreaPadding(.vertical, 32) +// +// if let scrollPosition { +// ScrollViewReader { proxy in +// view +// .animation( +// .smooth, +// body: { +// $0.scrollPosition(id: scrollPosition) +// } +// ) +// .onAppear { +// proxy.scrollTo(scrollPosition.wrappedValue) +// } +// } +// } else { +// view +// } + } + +} + +#Preview("ScrollView") { + + @Previewable @State var selection: Int? = 1 + + VStack { + HStack { + Button("One") { + selection = 0 + } + Button("Two") { + selection = 1 + } + Button("Three") { + selection = 2 + } + Button("Clear") { + selection = nil + } + } + Hopping(selection: $selection) { + ForEach(0..<10, id: \.self) { i in + Text("Item \(i)") + .background(.red.opacity(0.3)) + .id(i) + } + } + .onChange(of: selection, initial: true) { + print(selection) + } + } +}