From 4b95cb2883661d4749a1cfee6c3e3cfc539e99ad Mon Sep 17 00:00:00 2001 From: Pavel Holec Date: Wed, 24 Jul 2024 11:40:00 +0200 Subject: [PATCH] HorizontalScroll currently scrolled item change --- .../Orbit/Components/HorizontalScroll.swift | 47 ++++++++- .../Layout/HorizontalScrollPosition.swift | 95 +++++++++++++++++++ .../Layout/HorizontalScrollReader.swift | 2 + 3 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 Sources/Orbit/Support/Layout/HorizontalScrollPosition.swift diff --git a/Sources/Orbit/Components/HorizontalScroll.swift b/Sources/Orbit/Components/HorizontalScroll.swift index 48c5fcc9c35..3a7711b4caf 100644 --- a/Sources/Orbit/Components/HorizontalScroll.swift +++ b/Sources/Orbit/Components/HorizontalScroll.swift @@ -48,7 +48,7 @@ public struct HorizontalScroll: View { @State private var idPreferences: [IDPreference] = [] @State private var itemCount = 0 @State private var scrollViewWidth: CGFloat = 0 - + @State private var scrolledItemID: AnyHashable? @State private var scrollOffset: CGFloat = 0 @State private var lastOffset: CGFloat = 0 @State private var lastOffsetDifference: CGFloat? @@ -73,6 +73,7 @@ public struct HorizontalScroll: View { .onPreferenceChange(ScrollViewWidthPreferenceKey.self) { scrollViewWidth = $0 } + .preference(key: HorizontalScrollScrolledItemIDKey.self, value: scrolledItemID) .clipped() .padding(-clippingPadding) } @@ -190,16 +191,26 @@ public struct HorizontalScroll: View { return width } } + + private func scrollOffsetForItem(index: Int, geometry: GeometryProxy) -> CGFloat { + let itemToScrollTo = idPreferences[index] + let minX = geometry[itemToScrollTo.bounds].minX + let paddingOffset = index < itemCount - 2 ? screenLayoutHorizontalPadding : 0 + return offsetInBounds(offset: -minX + scrollOffset) + paddingOffset + } + + private func itemIndexForScrollOffset(scrollOffset: CGFloat) -> AnyHashable? { + let offset = offsetInBounds(offset: scrollOffset) - screenLayoutHorizontalPadding + let index = -Int((offset / (resolvedItemWidth + spacing)).rounded()) + return idPreferences.indices.contains(index) ? idPreferences[index].id : index + } private func scrollTo(id: AnyHashable, geometry: GeometryProxy, animated: Bool) { guard let preferenceIndex = idPreferences.firstIndex(where: { $0.id == id }) else { return } - let itemToScrollTo = idPreferences[preferenceIndex] - let minX = geometry[itemToScrollTo.bounds].minX - let paddingOffset = preferenceIndex < itemCount - 2 ? screenLayoutHorizontalPadding : 0 - let offset = offsetInBounds(offset: -minX + scrollOffset) + paddingOffset + let offset = scrollOffsetForItem(index: preferenceIndex, geometry: geometry) if animated { withAnimation(.easeOut(duration: programaticSnapAnimationDuration)) { @@ -272,6 +283,9 @@ public struct HorizontalScroll: View { guard isContentBiggerThanScrollView else { return } let modifiedOffset = offsetToSnap(gesture: gesture) + + scrolledItemID = itemIndexForScrollOffset(scrollOffset: modifiedOffset) + let offsetDifference = modifiedOffset - gesture.translation.width - lastOffset lastOffsetDifference = offsetDifference @@ -379,6 +393,11 @@ private struct ContentSizePreferenceKey: PreferenceKey { static func reduce(value _: inout CGSize, nextValue _: () -> CGSize) { /* Take first value */ } } +struct HorizontalScrollScrolledItemIDKey: PreferenceKey { + static var defaultValue: AnyHashable? + static func reduce(value _: inout AnyHashable?, nextValue _: () -> AnyHashable?) { /* Take first value */ } +} + struct HorizontalScrollOffsetPreferenceKey: PreferenceKey { static var defaultValue: CGFloat? @@ -406,6 +425,7 @@ struct HorizontalScrollPreviews: PreviewProvider { ratio fixed fitting + fullWidth insideCards } .screenLayout() @@ -463,6 +483,23 @@ struct HorizontalScrollPreviews: PreviewProvider { } .previewDisplayName() } + + static var fullWidth: some View { + VStack(alignment: .leading, spacing: .medium) { + Heading("Snapping", style: .title4) + + HorizontalScroll(isSnapping: true, itemWidth: .ratio(0.9)) { + tileVariants + } + + Heading("No snapping", style: .title4) + + HorizontalScroll(isSnapping: false, itemWidth: .ratio(1)) { + tileVariants + } + } + .previewDisplayName() + } static var insideCards: some View { VStack(alignment: .leading, spacing: .medium) { diff --git a/Sources/Orbit/Support/Layout/HorizontalScrollPosition.swift b/Sources/Orbit/Support/Layout/HorizontalScrollPosition.swift new file mode 100644 index 00000000000..ff3671f10ca --- /dev/null +++ b/Sources/Orbit/Support/Layout/HorizontalScrollPosition.swift @@ -0,0 +1,95 @@ +import SwiftUI + +@available(iOS 14, *) +struct HorizontalScrollPositionModifier: ViewModifier { + + @Binding var position: AnyHashable? + @State private var isModified = false + + func body(content: Content) -> some View { + HorizontalScrollReader { proxy in + content + .onChange(of: position) { + if isModified { + isModified = false + return + } + proxy.scrollTo($0) + } + .onPreferenceChange(HorizontalScrollScrolledItemIDKey.self) { + isModified = true + position = $0 + } + } + } +} + +public extension View { + + /// Associates a binding to be updated when an Orbit HorizontalScroll view within this view scrolls. + /// + /// Use this modifier along with the ``identifier(_:)`` and ``HorizontalScroll`` to know the identity of the view that is actively scrolled. As the scroll view scrolls, the binding will be updated with the identity of the leading-most / top-most view. + /// + /// You can also write to the binding to scroll to the view with the provided identity. + @available(iOS 14, *) + func horizontalScrollPosition(id: Binding) -> some View where Value: Hashable { + modifier( + HorizontalScrollPositionModifier( + position: .init( + get: { id.wrappedValue as AnyHashable? }, + set: { id.wrappedValue = $0 as? Value } + ) + ) + ) + } +} + +// MARK: - Previews +@available(iOS 14, *) +struct HorizontalScrollPositionModifierPreviews: PreviewProvider { + + static var previews: some View { + PreviewWrapper { + StateWrapper(Int?.none) { $id in + VStack(alignment: .leading, spacing: .medium) { + Heading("Snapping", style: .title3) + + HorizontalScroll(spacing: .medium, itemWidth: .ratio(0.95)) { + ForEach(0..<5) { index in + Tile("Tile \(index)", description: "Tap to scroll to previous") { + id = index - 1 + } + .identifier(index) + } + } + .horizontalScrollPosition(id: $id) + + Heading("Non snapping", style: .title3) + + HorizontalScroll(isSnapping: false, spacing: .medium, itemWidth: .ratio(0.95)) { + ForEach(0..<5) { index in + Tile("Tile \(index)", description: "Tap to scroll to previous") { + id = index - 1 + } + .identifier(index) + } + } + .horizontalScrollPosition(id: $id) + + Text("Scrolled item index: \(id ?? -1)") + + Heading("Scroll to:", style: .title3) + + HStack { + ForEach(0..<5) { index in + Button("\(index)") { + id = index + } + } + } + } + .screenLayout() + } + } + } +} diff --git a/Sources/Orbit/Support/Layout/HorizontalScrollReader.swift b/Sources/Orbit/Support/Layout/HorizontalScrollReader.swift index 8ae36a1b986..020b8fe15c5 100644 --- a/Sources/Orbit/Support/Layout/HorizontalScrollReader.swift +++ b/Sources/Orbit/Support/Layout/HorizontalScrollReader.swift @@ -13,6 +13,8 @@ public class HorizontalScrollViewProxy: ObservableObject { /// Orbit component that provides programmatic scrolling of ``HorizontalScroll`` component, /// by working with a ``HorizontalScrollViewProxy`` to scroll to child views marked by Orbit `identifier()` modifier. +/// +/// A ``horizontalScrollPosition(id:)`` can be used instead for a bidirectional management of currently scrolled item. @available(iOS 14, *) public struct HorizontalScrollReader: View {