Skip to content

Instantly share code, notes, and snippets.

@thatcherclough
Created November 12, 2024 04:06
Show Gist options
  • Select an option

  • Save thatcherclough/f29048332e51741a7bb4358d2d4169c3 to your computer and use it in GitHub Desktop.

Select an option

Save thatcherclough/f29048332e51741a7bb4358d2d4169c3 to your computer and use it in GitHub Desktop.
Swift UI Dynamic Feed View
//
// 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