Skip to content

Commit

Permalink
Merge pull request #818 from kiwicom/817-horizontalscroll-should-info…
Browse files Browse the repository at this point in the history
…rm-about-scroll-change

`HorizontalScroll` currently scrolled item change
  • Loading branch information
PavelHolec authored Jul 29, 2024
2 parents c8dac31 + 4b95cb2 commit ce07b08
Show file tree
Hide file tree
Showing 3 changed files with 139 additions and 5 deletions.
47 changes: 42 additions & 5 deletions Sources/Orbit/Components/HorizontalScroll.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public struct HorizontalScroll<Content: View>: 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?
Expand All @@ -73,6 +73,7 @@ public struct HorizontalScroll<Content: View>: View {
.onPreferenceChange(ScrollViewWidthPreferenceKey.self) {
scrollViewWidth = $0
}
.preference(key: HorizontalScrollScrolledItemIDKey.self, value: scrolledItemID)
.clipped()
.padding(-clippingPadding)
}
Expand Down Expand Up @@ -190,16 +191,26 @@ public struct HorizontalScroll<Content: View>: 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)) {
Expand Down Expand Up @@ -272,6 +283,9 @@ public struct HorizontalScroll<Content: View>: View {
guard isContentBiggerThanScrollView else { return }

let modifiedOffset = offsetToSnap(gesture: gesture)

scrolledItemID = itemIndexForScrollOffset(scrollOffset: modifiedOffset)

let offsetDifference = modifiedOffset - gesture.translation.width - lastOffset
lastOffsetDifference = offsetDifference

Expand Down Expand Up @@ -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?

Expand Down Expand Up @@ -406,6 +425,7 @@ struct HorizontalScrollPreviews: PreviewProvider {
ratio
fixed
fitting
fullWidth
insideCards
}
.screenLayout()
Expand Down Expand Up @@ -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) {
Expand Down
95 changes: 95 additions & 0 deletions Sources/Orbit/Support/Layout/HorizontalScrollPosition.swift
Original file line number Diff line number Diff line change
@@ -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<Value>(id: Binding<Value?>) -> 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()
}
}
}
}
2 changes: 2 additions & 0 deletions Sources/Orbit/Support/Layout/HorizontalScrollReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Content: View>: View {

Expand Down

0 comments on commit ce07b08

Please sign in to comment.