|
/// 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 |
|
) |
|
} |
|
} |
|
} |
example usage