Skip to content

Instantly share code, notes, and snippets.

@JadenGeller
Last active July 13, 2025 07:02
Show Gist options
  • Save JadenGeller/a20e1b2cd6434d7755a50d3fe8f6c752 to your computer and use it in GitHub Desktop.
Save JadenGeller/a20e1b2cd6434d7755a50d3fe8f6c752 to your computer and use it in GitHub Desktop.
Keep view state and identity while animating into it from a list
/// A view that displays a collection of items in a scrollable list that can "zoom" to focus on a single selected item.
///
/// `ZoomNavigator` provides a navigation pattern similar to the iOS lock screen wallpaper picker,
/// where items can be viewed together in a scrollable list or individually in full screen.
/// The same view instance is maintained during transitions, preserving state and identity.
///
/// When no item is selected (`selection` is `nil`), all items are displayed in a scrollable list.
/// When an item is selected, the view filters to show only that item, expanded to fill the available space.
struct ZoomNavigator<Data: RandomAccessCollection, ID: Hashable, Content: View, Background: View, Modifier: ViewModifier>: View {
var data: Data
var id: KeyPath<Data.Element, ID>
var selection: ID?
var alignment: Alignment = .center
var axes: Axis.Set = .vertical // btw: for horizontal, collapsed behavior isn't identical
var showsIndicators: Bool = true
@ViewBuilder var content: (Data.Element) -> Content
@ViewBuilder var background: (Data.Element) -> Background
var modifier: (Data.Element) -> Modifier
var isZoomedOut: Bool {
selection == nil
}
var body: some View {
GeometryReader { geometry in
ScrollView(isZoomedOut ? axes : .vertical, showsIndicators: true) {
let data = data.filter { item in
if let selection {
item[keyPath: id] == selection
} else {
true
}
}
ForEach(data, id: id) { item in
content(item)
.frame(
minWidth: isZoomedOut ? nil : geometry.size.width,
minHeight: isZoomedOut ? nil : geometry.size.height,
alignment: alignment
)
.background(
background(item)
.frame(
minWidth: isZoomedOut ? nil : geometry.size.width
+ geometry.safeAreaInsets.leading
+ geometry.safeAreaInsets.trailing,
minHeight: isZoomedOut ? nil : geometry.size.height
+ geometry.safeAreaInsets.top
+ geometry.safeAreaInsets.bottom
)
.offset(
x: isZoomedOut ? 0 : -geometry.safeAreaInsets.leading,
y: isZoomedOut ? 0 : -geometry.safeAreaInsets.top
),
alignment: .topLeading
)
.modifier(modifier(item))
}
}
.scrollDisabled(true)
.frame(
minWidth: axes.contains(.vertical) ? geometry.size.width : nil,
minHeight: axes.contains(.horizontal) ? geometry.size.height : nil,
alignment: alignment
)
}
}
}
@JadenGeller
Copy link
Author

example usage

struct TapGestureModifier: ViewModifier {
    var onTapGesture: () -> Void
    
    func body(content: Content) -> some View {
        content.onTapGesture {
            onTapGesture()
        }
    }
}

struct ContentView: View {
    var colors: [Color] = [.red, .orange, .yellow, .green, .blue]
    @State var selection: Int? = nil
    var isZoomedIn: Bool { selection != nil }
    
    var body: some View {
        ZoomNavigator(data: 0..<5, id: \.self, selection: selection) { i in
            VStack {
                Text("Hello \(i)")
                    .padding()
                Spacer()
                if isZoomedIn {
                    Text("I really like the number \(i)")
                }
            }
            .frame(maxWidth: .infinity)
        } background: { i in
            colors[i]
        } modifier: { i in
            TapGestureModifier {
                withAnimation {
                    if selection == nil {
                        self.selection = i
                    } else {
                        self.selection = nil
                    }
                }
            }
        }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment