// // https://mczachurski.dev // Copyright © 2023 Marcin Czachurski and the repository contributors. // Licensed under the Apache License 2.0. // import SwiftUI import WidgetsKit import Semaphore struct WaterfallGrid: View where Data: RandomAccessCollection, Data: Equatable, Content: View, ID: Hashable, Data.Element: Equatable, Data.Element: Identifiable, Data.Element: Hashable, Data.Element: Sizable { @Binding private var columns: Int @Binding private var hideLoadMore: Bool @Binding private var data: Data @Binding private var refreshId: String private let content: (Data.Element) -> Content @State private var columnsData: [ColumnData] = [] @State private var processedItems: [Data.Element.ID] = [] @State private var shouldRecalculate = false private let onLoadMore: () async -> Void private let semaphore = AsyncSemaphore(value: 1) var body: some View { HStack(alignment: .top, spacing: 8) { ForEach(self.columnsData, id: \.id) { columnData in LazyVStack(spacing: 8) { ForEach(columnData.data, id: \.id) { item in self.content(item) } if self.hideLoadMore == false { // We can show multiple loading indicators. Each indicator can run loading feature in pararell. // Thus we have to be sure that loading will exeute one by one. LoadingIndicator() .task { Task { @MainActor in await self.loadMoreData() } } } } } } .onFirstAppear { self.recalculateArrays() } .onChange(of: self.refreshId) { self.shouldRecalculate = true } .onChange(of: self.data) { if self.shouldRecalculate { self.recalculateArrays() self.shouldRecalculate = false } else { self.appendToArrays() } } .onChange(of: self.columns) { self.recalculateArrays() } } private func loadMoreData() async { await semaphore.wait() defer { semaphore.signal() } await self.onLoadMore() } private func recalculateArrays() { Task { @MainActor in await semaphore.wait() defer { semaphore.signal() } self.columnsData = [] self.processedItems = [] for _ in 0 ..< self.columns { self.columnsData.append(ColumnData()) } for item in self.data { let index = self.minimumHeightIndex() self.columnsData[index].data.append(item) self.columnsData[index].height = self.columnsData[index].height + self.calculateHeight(item: item) self.processedItems.append(item.id) } } } private func appendToArrays() { Task { @MainActor in await semaphore.wait() defer { semaphore.signal() } for item in self.data where self.processedItems.contains(where: { $0 == item.id }) == false { let index = self.minimumHeightIndex() self.columnsData[index].data.append(item) self.columnsData[index].height = self.columnsData[index].height + self.calculateHeight(item: item) self.processedItems.append(item.id) } } } private func calculateHeight(item: Sizable) -> Double { return item.height / item.width } private func minimumHeight() -> Double { return self.columnsData.map({ $0.height }).min() ?? .zero } private func minimumHeightIndex() -> Int { let minimumHeight = self.minimumHeight() return self.columnsData.firstIndex(where: { $0.height == minimumHeight }) ?? 0 } } extension WaterfallGrid { init(_ data: Binding, refreshId: Binding, columns: Binding, hideLoadMore: Binding, content: @escaping (Data.Element) -> Content, onLoadMore: @escaping () async -> Void) { self.content = content self.onLoadMore = onLoadMore self._data = data self._columns = columns self._hideLoadMore = hideLoadMore self._refreshId = refreshId } } extension WaterfallGrid where ID == Data.Element.ID, Data.Element: Identifiable { init(_ data: Binding, refreshId: Binding, columns: Binding, hideLoadMore: Binding, content: @escaping (Data.Element) -> Content, onLoadMore: @escaping () async -> Void) { self.content = content self.onLoadMore = onLoadMore self._data = data self._columns = columns self._hideLoadMore = hideLoadMore self._refreshId = refreshId } }