Created
November 12, 2024 04:06
-
-
Save thatcherclough/f29048332e51741a7bb4358d2d4169c3 to your computer and use it in GitHub Desktop.
Swift UI Dynamic Feed View
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // FeedView.swift | |
| // Clear30 | |
| // | |
| // Created by Thatcher Clough on 11/11/24. | |
| // | |
| // Note: Page Dots from https://github.com/notsobigcompany/BigUIPaging | |
| // | |
| import SwiftUI | |
| private struct FeedFocusKey: EnvironmentKey { | |
| static let defaultValue = true | |
| } | |
| extension EnvironmentValues { | |
| var isFeedFocused: Bool { | |
| get { self[FeedFocusKey.self] } | |
| set { self[FeedFocusKey.self] = newValue } | |
| } | |
| } | |
| final class MeasurementCache: ObservableObject { | |
| @Published private(set) var sizes: [Int: CGSize] = [:] | |
| private var pendingUpdates: [Int: CGSize] = [:] | |
| private var updateWorkItem: DispatchWorkItem? | |
| func updateSize(_ size: CGSize, for index: Int) { | |
| if sizes[index] == size { return } | |
| updateWorkItem?.cancel() | |
| pendingUpdates[index] = size | |
| let workItem = DispatchWorkItem { [weak self] in | |
| guard let self = self else { return } | |
| DispatchQueue.main.async { | |
| let hasChanges = self.pendingUpdates | |
| .contains { index, size in | |
| self.sizes[index] != size | |
| } | |
| if hasChanges { | |
| self.sizes.merge(self.pendingUpdates) { $1 } | |
| } | |
| self.pendingUpdates.removeAll() | |
| } | |
| } | |
| self.updateWorkItem = workItem | |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem) | |
| } | |
| func getSize(for index: Int) -> CGSize? { | |
| return pendingUpdates[index] ?? sizes[index] | |
| } | |
| } | |
| struct MeasurementModifier: ViewModifier { | |
| let id: Int | |
| @ObservedObject var cache: MeasurementCache | |
| func body(content: Content) -> some View { | |
| content.background( | |
| GeometryReader { proxy in | |
| Color.clear | |
| .onAppear { | |
| cache.updateSize(proxy.size, for: id) | |
| } | |
| .onChange(of: proxy.size) { _, newSize in | |
| cache.updateSize(newSize, for: id) | |
| } | |
| } | |
| ) | |
| } | |
| } | |
| struct FeedView<Views>: View where Views: View { | |
| let viewCount: Int | |
| let viewBuilder: (Int, CGSize, @escaping () -> Void) -> Views | |
| // Configuration | |
| let spacing: CGFloat | |
| let isHorizontal: Bool | |
| let showNavAffordance: Bool | |
| let edgePadding: CGFloat | |
| let clipped: Bool | |
| let indicatorColor: Color | |
| let indicatorActiveColor: Color | |
| @StateObject private var measurementCache = MeasurementCache() | |
| @Environment(\.isFeedFocused) private var isParentFocused: Bool | |
| @Environment(\.colorScheme) private var colorScheme | |
| @State private var currentIndex: Int = 0 | |
| @State private var dragOffset: CGFloat = 0 | |
| @State private var isDragging: Bool = false | |
| var onFirst: Bool { currentIndex == 0 } | |
| var onLast: Bool { currentIndex == viewCount - 1 } | |
| let animation = Animation.spring(response: 0.3, dampingFraction: 0.8) | |
| init( | |
| viewCount: Int, | |
| spacing: CGFloat = 16, | |
| isHorizontal: Bool = false, | |
| showNavAffordance: Bool = true, | |
| edgePadding: CGFloat? = nil, | |
| clipped: Bool = true, | |
| indicatorColor: Color = Color.gray.opacity(0.25), | |
| indicatorActiveColor: Color = Color.gray.opacity(0.8), | |
| @ViewBuilder content: @escaping (Int, CGSize, @escaping () -> Void) -> Views | |
| ) { | |
| self.viewCount = viewCount | |
| self.spacing = spacing | |
| self.isHorizontal = isHorizontal | |
| self.showNavAffordance = showNavAffordance | |
| self.edgePadding = edgePadding ?? spacing | |
| self.clipped = clipped | |
| self.indicatorColor = indicatorColor | |
| self.indicatorActiveColor = indicatorActiveColor | |
| self.viewBuilder = content | |
| } | |
| var body: some View { | |
| ZStack { | |
| VStack(spacing: 0) { | |
| GeometryReader { geometry in | |
| let availableSpace = CGSize( | |
| width: geometry.size.width, | |
| height: geometry.size.height | |
| ) | |
| Group { | |
| if isHorizontal { | |
| LazyHStack(spacing: spacing) { | |
| ForEach(0..<viewCount, id: \.self) { index in | |
| viewBuilder(index, availableSpace) { | |
| if !onLast { | |
| withAnimation(animation) { | |
| currentIndex += 1 | |
| } | |
| } | |
| } | |
| .environment(\.isFeedFocused, isParentFocused && currentIndex == index) | |
| .modifier(MeasurementModifier( | |
| id: index, | |
| cache: measurementCache | |
| )) | |
| .padding(.leading, index == 0 ? edgePadding: 0) | |
| .padding(.trailing, index == viewCount - 1 ? edgePadding: 0) | |
| .allowsHitTesting(!isDragging && currentIndex == index) | |
| .opacity(currentIndex == index ? 1 : 0.75) | |
| .scaleEffect(currentIndex == index ? 1 : 0.98) | |
| .overlay { | |
| if currentIndex != index { | |
| Color.clear | |
| .contentShape(Rectangle()) | |
| .onTapGesture { | |
| currentIndex = index | |
| } | |
| } | |
| } | |
| } | |
| } | |
| .offset(x: calculateStackOffset(in: geometry)) | |
| } else { | |
| LazyVStack(spacing: spacing) { | |
| ForEach(0..<viewCount, id: \.self) { index in | |
| viewBuilder(index, availableSpace) { | |
| if !onLast { | |
| withAnimation(animation) { | |
| currentIndex += 1 | |
| } | |
| } | |
| } | |
| .environment(\.isFeedFocused, isParentFocused && currentIndex == index) | |
| .padding(.top, index == 0 ? edgePadding : 0) | |
| .padding(.bottom, index == viewCount - 1 ? edgePadding : 0) | |
| .modifier(MeasurementModifier( | |
| id: index, | |
| cache: measurementCache | |
| )) | |
| .allowsHitTesting(!isDragging && currentIndex == index) | |
| .opacity(currentIndex == index ? 1 : 0.75) | |
| .scaleEffect(currentIndex == index ? 1 : 0.98) | |
| .overlay { | |
| if currentIndex != index { | |
| Color.clear | |
| .contentShape(Rectangle()) | |
| .onTapGesture { | |
| currentIndex = index | |
| } | |
| } | |
| } | |
| } | |
| } | |
| .offset(y: calculateStackOffset(in: geometry)) | |
| } | |
| } | |
| .animation(animation, value: currentIndex) | |
| .animation(animation, value: dragOffset) | |
| .simultaneousGesture( | |
| DragGesture(minimumDistance: 10) | |
| .onChanged { value in | |
| handleDragChange(value: value) | |
| } | |
| .onEnded { value in | |
| handleDragEnd(value: value) | |
| } | |
| ) | |
| } | |
| if showNavAffordance && isHorizontal && viewCount > 1 { | |
| pageDots | |
| } | |
| } | |
| if showNavAffordance && !isHorizontal { | |
| VStack { | |
| Spacer() | |
| if currentIndex < viewCount - 1 { | |
| Image(systemName: "chevron.down") | |
| .foregroundColor(.gray) | |
| .padding(.bottom, spacing / 2) | |
| } | |
| } | |
| } | |
| } | |
| .if(clipped) { view in | |
| view.clipped() | |
| } | |
| } | |
| private func calculateStackOffset(in geometry: GeometryProxy) -> CGFloat { | |
| var offset: CGFloat = 0 | |
| if !onFirst || viewCount == 1 { | |
| let containerDimension = isHorizontal ? geometry.size.width : geometry.size.height | |
| let currentViewSize = measurementCache.getSize(for: currentIndex).map { isHorizontal ? $0.width : $0.height } ?? 0 | |
| if onLast && viewCount != 1 { | |
| offset = (containerDimension - currentViewSize) | |
| } else { | |
| offset = (containerDimension - currentViewSize) / 2 | |
| } | |
| } | |
| for i in 0..<currentIndex { | |
| if let size = measurementCache.getSize(for: i) { | |
| offset -= (isHorizontal ? size.width : size.height) + spacing | |
| } | |
| } | |
| return offset + dragOffset | |
| } | |
| private func handleDragChange(value: DragGesture.Value) { | |
| if isHorizontal && abs(value.translation.height) > abs(value.translation.width) || | |
| !isHorizontal && abs(value.translation.width) > abs(value.translation.height) { | |
| return | |
| } | |
| isDragging = true | |
| let translation = isHorizontal ? value.translation.width : value.translation.height | |
| if (onFirst && translation > 0) || | |
| (onLast && translation < 0) { | |
| dragOffset = translation * 0.2 | |
| } else { | |
| dragOffset = translation | |
| } | |
| } | |
| private func handleDragEnd(value: DragGesture.Value) { | |
| let velocity = isHorizontal ? value.predictedEndTranslation.width : value.predictedEndTranslation.height | |
| let translation = isHorizontal ? value.translation.width : value.translation.height | |
| let currentSize = measurementCache.getSize(for: currentIndex).map { isHorizontal ? $0.width : $0.height } ?? 100 | |
| let threshold = currentSize * 0.25 | |
| if abs(translation) > threshold || abs(velocity) > 800 { | |
| if translation > 0 && !onFirst { | |
| currentIndex -= 1 | |
| } else if translation < 0 && !onLast { | |
| currentIndex += 1 | |
| } | |
| } | |
| dragOffset = 0 | |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { | |
| isDragging = false | |
| } | |
| } | |
| var pageDots: some View { | |
| PageIndicator(selection: $currentIndex, total: viewCount) | |
| .pageIndicatorColor(indicatorColor) | |
| .pageIndicatorCurrentColor(indicatorActiveColor) | |
| .pageIndicatorBackgroundStyle(.prominent) | |
| .allowsContinuousInteraction(true) | |
| .padding(.top, spacing) | |
| } | |
| } | |
| extension View { | |
| func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View { | |
| if condition { | |
| transform(self) | |
| } else { | |
| self | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment