Last active
June 21, 2025 03:09
-
-
Save rlpavel/75edc2ca2a9e1547a5e680a754fae31f to your computer and use it in GitHub Desktop.
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
| 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 | |
| ) | |
| } | |
| } |
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
| #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