Skip to content

Instantly share code, notes, and snippets.

@rlpavel
Last active June 21, 2025 03:09
Show Gist options
  • Save rlpavel/75edc2ca2a9e1547a5e680a754fae31f to your computer and use it in GitHub Desktop.
Save rlpavel/75edc2ca2a9e1547a5e680a754fae31f to your computer and use it in GitHub Desktop.
import SwiftUI
/// `PartialForEach` is used to show a partial number of items, and a view builder to toggle the expansion.
public struct PartialForEach<Data: RandomAccessCollection, ID: Hashable, ItemView: View, ExpandView: View> {
public enum ExpandPlacement {
case first, last
}
/// This type defines a lazy map from `Data.indices` to `(index, (value, id))` tuple.
/// The `index` is required for bindable `ForEach` initializer.
typealias LazyEnumeratedIdentifiedData = LazyMapSequence<
LazySequence<Data.Indices>.Elements,
(index: LazySequence<Data.Indices>.Element, item: (value: Binding<Data.Element>, id: ID))
>.SubSequence
var data: LazyEnumeratedIdentifiedData
let id: KeyPath<Data.Element, ID>
let prefix: LazySequence<Data.Indices>.Element
var isExpanded: Bool
private let isAlwaysShowingAll: Bool
let expandPlacement: ExpandPlacement
let isCollapsible: Bool
let content: (Binding<Data.Element>) -> ItemView
let expandView: () -> ExpandView
private init(
lazyEnumeratedIdentifiedData: LazyEnumeratedIdentifiedData,
id: KeyPath<Data.Element, ID>,
prefix: LazySequence<Data.Indices>.Element,
isExpanded: Bool,
expandPlacement: ExpandPlacement,
isCollapsible: Bool,
content: @escaping (Binding<Data.Element>) -> ItemView,
expandView: @escaping () -> ExpandView
) {
let partialLimit = min(prefix, lazyEnumeratedIdentifiedData.endIndex)
self.data = lazyEnumeratedIdentifiedData
.prefix(upTo: isExpanded ? lazyEnumeratedIdentifiedData.endIndex : partialLimit)
self.id = id
self.prefix = prefix
self.isExpanded = isExpanded
self.isAlwaysShowingAll = prefix > data.endIndex
self.expandPlacement = expandPlacement
self.isCollapsible = isCollapsible
self.content = content
self.expandView = expandView
}
}
extension PartialForEach: View {
public var body: some View {
if expandPlacement == .first && (!isExpanded || isCollapsible) && !isAlwaysShowingAll {
expandView()
}
ForEach(data, id: \.item.id) { element in
content(element.item.value)
}
if expandPlacement == .last && (!isExpanded || isCollapsible) && !isAlwaysShowingAll {
expandView()
}
}
}
public extension PartialForEach {
/// Creates a view to show a partial number of items, and a view builder to toggle the expansion
/// - Parameters:
/// - data: The full collection of Identifiable items to show.
/// - prefix: The number of items to show in the collapsed state.
/// - isExpanded: Whether the the collection is expanded to show all data.
/// - expandPlacement: The placement of the `expandView` - either `before` or `after` the data.
/// - isCollapsible: Whether the data can be collapsed after it was expanded. If `false`, `expandView` will
/// be hidden after the data got expanded..
/// - content: View builder for the data element.
/// - expandView: View builder to toggle showing the full list of items.
init(
_ data: Data,
prefix: LazySequence<Data.Indices>.Element,
isExpanded: Bool,
expandPlacement: ExpandPlacement = .last,
isCollapsible: Bool = false,
@ViewBuilder content: @escaping (Data.Element) -> ItemView,
@ViewBuilder expandView: @escaping () -> ExpandView
) where Data.Element: Identifiable, ID == Data.Element.ID {
self.init(
lazyEnumeratedIdentifiedData: data.indices.lazy.map { index in
(index: index, item: (value: .constant(data[index]), id: data[index].id))
},
id: \.id,
prefix: prefix,
isExpanded: isExpanded,
expandPlacement: expandPlacement,
isCollapsible: isCollapsible,
content: { content($0.wrappedValue) },
expandView: expandView
)
}
/// Creates a view to show a partial number of items, and a view builder to toggle the expansion
/// - Parameters:
/// - data: The full collection of items to show.
/// - id: Key path to identify the item.
/// - prefix: The number of items to show in the collapsed state.
/// - isExpanded: Whether the the collection is expanded to show all data.
/// - expandPlacement: The placement of the `expandView` - either `before` or `after` the data.
/// - isCollapsible: Whether the data can be collapsed after it was expanded. If `false`, `expandView` will
/// be hidden after the data got expanded..
/// - content: View builder for the data element.
/// - expandView: View builder to toggle showing the full list of items.
init(
_ data: Data,
id: KeyPath<Data.Element, ID>,
prefix: LazySequence<Data.Indices>.Element,
isExpanded: Bool,
expandPlacement: ExpandPlacement = .last,
isCollapsible: Bool = false,
@ViewBuilder content: @escaping (Data.Element) -> ItemView,
@ViewBuilder expandView: @escaping () -> ExpandView
) {
self.init(
lazyEnumeratedIdentifiedData: data.indices.lazy.map { index in
(index: index, item: (value: .constant(data[index]), id: data[index][keyPath: id]))
},
id: id,
prefix: prefix,
isExpanded: isExpanded,
expandPlacement: expandPlacement,
isCollapsible: isCollapsible,
content: { content($0.wrappedValue) },
expandView: expandView
)
}
}
public extension PartialForEach where Data: MutableCollection {
/// Creates a view to show a partial number of items, and a view builder to toggle the expansion
/// - Parameters:
/// - data: Binding to the full collection of items to show.
/// - prefix: The number of items to show in the collapsed state.
/// - isExpanded: Whether the the collection is expanded to show all data.
/// - expandPlacement: The placement of the `expandView` - either `before` or `after` the data.
/// - isCollapsible: Whether the data can be collapsed after it was expanded. If `false`, `expandView` will
/// be hidden after the data got expanded..
/// - content: View builder for the data element binding.
/// - expandView: View builder to toggle showing the full list of items.
init(
_ data: Binding<Data>,
prefix: LazySequence<Data.Indices>.Element,
isExpanded: Bool,
expandPlacement: ExpandPlacement = .last,
isCollapsible: Bool = false,
content: @escaping (Binding<Data.Element>) -> ItemView,
expandView: @escaping () -> ExpandView
) where Data.Element: Identifiable, ID == Data.Element.ID {
self.init(
lazyEnumeratedIdentifiedData: data.indices.lazy.map { index in
(index: index, item: (value: data[index], id: data[index].id))
},
id: \.id,
prefix: prefix,
isExpanded: isExpanded,
expandPlacement: expandPlacement,
isCollapsible: isCollapsible,
content: content,
expandView: expandView
)
}
/// Creates a view to show a partial number of items, and a view builder to toggle the expansion
/// - Parameters:
/// - data: Binding to the full collection of items to show.
/// - id: Key path to identify the item.
/// - prefix: The number of items to show in the collapsed state.
/// - isExpanded: Whether the the collection is expanded to show all data.
/// - expandPlacement: The placement of the `expandView` - either `before` or `after` the data.
/// - isCollapsible: Whether the data can be collapsed after it was expanded. If `false`, `expandView` will
/// be hidden after the data got expanded..
/// - content: View builder for the data element binding.
/// - expandView: View builder to toggle showing the full list of items.
init(
_ data: Binding<Data>,
id: KeyPath<Data.Element, ID>,
prefix: LazySequence<Data.Indices>.Element,
isExpanded: Bool,
expandPlacement: ExpandPlacement = .last,
isCollapsible: Bool = false,
@ViewBuilder content: @escaping (Binding<Data.Element>) -> ItemView,
@ViewBuilder expandView: @escaping () -> ExpandView
) {
self.init(
lazyEnumeratedIdentifiedData: data.indices.lazy.map { index in
(index: index, item: (value: data[index], id: data[index].wrappedValue[keyPath: id]))
},
id: id,
prefix: prefix,
isExpanded: isExpanded,
expandPlacement: expandPlacement,
isCollapsible: isCollapsible,
content: content,
expandView: expandView
)
}
}
#Preview {
struct Preview: View {
struct Item: Identifiable, Equatable {
let id = UUID()
var isOn = false
}
class ViewModel: ObservableObject {
@Published var items: [Item]
init() {
items = (0..<15).map { _ in Item() }
}
}
@StateObject var viewModel = ViewModel()
@State var isExpanded1 = false
@State var isExpanded2 = false
@State var isExpanded3 = false
let constantItems = (0..<7).map { _ in Item() }
var body: some View {
ScrollView {
VStack(spacing: 24) {
VStack {
PartialForEach(
constantItems,
prefix: 3,
isExpanded: isExpanded1,
isCollapsible: false
) { item in
Text(item.id.uuidString)
} expandView: {
Button { isExpanded1 = true } label: {
Text("Show all")
}
}
}
Divider()
VStack {
PartialForEach(
$viewModel.items,
prefix: 3,
isExpanded: isExpanded2,
expandPlacement: .first,
isCollapsible: true
) { item in
Toggle(item.wrappedValue.id.uuidString, isOn: item.isOn)
} expandView: {
Button { isExpanded2.toggle() } label: {
Text(isExpanded2 ? "Hide ^" : "Show all v")
}
}
}
Divider()
HStack {
PartialForEach(
(0..<10),
id: \.self,
prefix: 5,
isExpanded: isExpanded3
) { item in
Text("\(item)")
} expandView: {
Button { isExpanded3 = true } label: {
Text("Show all")
}
}
}
}
.font(.footnote)
.padding(.horizontal)
}
}
}
return Preview()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment