diff --git a/CollectionThing.xcodeproj/project.pbxproj b/CollectionThing.xcodeproj/project.pbxproj index 141a44d..f0818cb 100644 --- a/CollectionThing.xcodeproj/project.pbxproj +++ b/CollectionThing.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 365E19532385C868003FC01B /* FastCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 365E19522385C868003FC01B /* FastCollection.swift */; }; 83060F432381FE2000FDF0AD /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83060F422381FE2000FDF0AD /* AppDelegate.swift */; }; 83060F452381FE2000FDF0AD /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83060F442381FE2000FDF0AD /* SceneDelegate.swift */; }; 83060F472381FE2000FDF0AD /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83060F462381FE2000FDF0AD /* ContentView.swift */; }; @@ -21,6 +22,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 365E19522385C868003FC01B /* FastCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastCollection.swift; sourceTree = ""; }; 83060F3F2381FE2000FDF0AD /* CollectionThing.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CollectionThing.app; sourceTree = BUILT_PRODUCTS_DIR; }; 83060F422381FE2000FDF0AD /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 83060F442381FE2000FDF0AD /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -82,6 +84,7 @@ 83060F422381FE2000FDF0AD /* AppDelegate.swift */, 83060F442381FE2000FDF0AD /* SceneDelegate.swift */, 83060F462381FE2000FDF0AD /* ContentView.swift */, + 365E19522385C868003FC01B /* FastCollection.swift */, 83060F482381FE2200FDF0AD /* Assets.xcassets */, 83060F4D2381FE2200FDF0AD /* LaunchScreen.storyboard */, 83060F502381FE2200FDF0AD /* Info.plist */, @@ -222,6 +225,7 @@ buildActionMask = 2147483647; files = ( 83060F432381FE2000FDF0AD /* AppDelegate.swift in Sources */, + 365E19532385C868003FC01B /* FastCollection.swift in Sources */, 83060F452381FE2000FDF0AD /* SceneDelegate.swift in Sources */, 83060F472381FE2000FDF0AD /* ContentView.swift in Sources */, ); @@ -308,7 +312,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.2; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -362,7 +366,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.2; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; diff --git a/CollectionThing/ContentView.swift b/CollectionThing/ContentView.swift index 5c0b038..e875da6 100644 --- a/CollectionThing/ContentView.swift +++ b/CollectionThing/ContentView.swift @@ -7,65 +7,6 @@ // import SwiftUI -import Combine - -struct WrappedLayout { - /// The items to be laid out - let items: [Item] - - /// The (maximum) number of items to be placed in a row - let columns: Int - - /// A model representing the row of items - struct Row: Identifiable { - let id: Int - let frame: CGRect - let items: ArraySlice - - func translatingY(_ y: CGFloat) -> Row { - return Row(id: id, frame: frame.offsetBy(dx: 0, dy: y), items: items) - } - - func width(_ width: CGFloat) -> Row { - return Row(id: id, frame: CGRect(x: frame.origin.x, y: frame.origin.y, width: width, height: frame.size.height), items: items) - } - } - - let contentSize: CGSize - let rows: [Row] - let rowHeight: CGFloat - - init(items: [Item], columns: Int) { - let rowHeight: CGFloat = 80 - - func rangeForRow(_ index: Int) -> Range { - return ((index * columns) ..< ((index + 1) * columns)).clamped(to: items.indices) - } - - func frameForRow(_ index: Int) -> CGRect { - return CGRect(x: 0, y: CGFloat(index) * rowHeight, width: 1, height: rowHeight) - } - - let (quotient, remainder) = items.count.quotientAndRemainder(dividingBy: columns) - let rowCount = (remainder > 0) ? quotient + 1 : quotient - - self.items = items - self.rows = (0 ..< rowCount).map { Row(id: $0, frame: frameForRow($0), items: items[rangeForRow($0)]) } - self.columns = columns - self.contentSize = CGSize(width: 1, height: CGFloat(rowCount) * rowHeight) - self.rowHeight = rowHeight - } - - struct LayoutItem: Identifiable { - var id: Item.ID { item.id } - let item: Item - let frame: CGRect - } - - func rows(in rect: CGRect) -> [Row] { - return rows.filter { $0.frame.intersects(rect) }.map { $0.width(rect.width) } - } -} struct Item: Identifiable { let id = UUID() @@ -87,128 +28,20 @@ struct ItemView: View { } } -final class Store: ObservableObject { - @Published var value: WrappedLayout - init() { - self.value = WrappedLayout(items: (0 ..< 50000).map { Item(title: "\($0)") }, columns: 8) - } -} - struct ContentView: View { - - @ObservedObject var store: Store - init() { - self.store = Store() - } - - @State var fixedBounds: CGRect = .zero - @State var lastQueryRect: CGRect = .zero - @State var visibleRowBounds: CGRect = .zero - @State var visibleRows: [WrappedLayout.Row] = [] - + @ObservedObject private var store = Store() + var body: some View { - VStack { - ScrollView { - ZStack(alignment: .top) { - MovingView() - .frame(height: 0) - - Color.clear - .frame( - width: self.store.value.contentSize.width, - height: self.store.value.contentSize.height - ) - .hidden() - - VStack(spacing: 0) { - ForEach(self.visibleRows) { row in - HStack(spacing: 0) { - ForEach(row.items) { item in - ItemView(item: item) - } - } - } - } - .frame( - width: self.fixedBounds.width, - height: self.visibleRowBounds.height, - alignment: .topLeading - ) - .position( - x: self.fixedBounds.midX, - y: self.visibleRowBounds.midY - ) - } - } - .background(FixedView().edgesIgnoringSafeArea(.all)) - .onPreferenceChange(ViewFrames.self) { values in - let fixedBounds = values[.fixedView] ?? .zero - let movingBounds = values[.movingView] ?? .zero - let boundsDirty = fixedBounds != self.fixedBounds - if boundsDirty { - self.fixedBounds = values[.fixedView] ?? .zero - } - - #if os(iOS) - let visibleRect = CGRect( - x: movingBounds.origin.x, - y: (fixedBounds.origin.y - movingBounds.origin.y), - width: fixedBounds.width, - height: fixedBounds.height) - #else - let visibleRect = CGRect( - x: movingBounds.origin.x, - y: movingBounds.origin.y - fixedBounds.height, - width: fixedBounds.width, - height: fixedBounds.height) - #endif - - let queryRect = visibleRect.insetBy(dx: 0, dy: -(visibleRect.height / 8)) - - if boundsDirty || self.lastQueryRect.isEmpty || self.lastQueryRect.intersection(queryRect).height < (visibleRect.height * 1.2) { - self.lastQueryRect = queryRect - - let rows = self.store.value.rows(in: queryRect) - let bounds = (rows.first?.frame ?? .zero).union(rows.last?.frame ?? .zero) - - if rows.map({ $0.id }) != self.visibleRows.map({ $0.id }) { - self.visibleRows = rows - self.visibleRowBounds = bounds - } - } - } - } - } - - struct MovingView: View { - var body: some View { - GeometryReader { proxy in - Color.clear.hidden().preference(key: ViewFrames.self, value: [.movingView: proxy.frame(in: .global)]) - }.frame(height: 0) + FastCollection(items: store.value, columns: 8, buffer: 100, itemHeight: 80) { item in + ItemView(item: item) } } - - struct FixedView: View { - var body: some View { - GeometryReader { proxy in - Color.clear.hidden().preference(key: ViewFrames.self, value: [.fixedView: proxy.frame(in: .global)]) - } - } - } - - struct ViewFrames: PreferenceKey { - enum ViewType: Int { - case movingView - case fixedView - } - - static var defaultValue: [ViewType:CGRect] = [:] - - static func reduce(value: inout [ViewType:CGRect], nextValue: () -> [ViewType:CGRect]) { - value.merge(nextValue(), uniquingKeysWith: { old, new in new }) - } +} - typealias Value = [ViewType:CGRect] +final class Store: ObservableObject { + @Published var value: [Item] + init() { + self.value = (0 ..< 50000).map { Item(title: "\($0)") } } } diff --git a/CollectionThing/FastCollection.swift b/CollectionThing/FastCollection.swift new file mode 100644 index 0000000..4cb3c93 --- /dev/null +++ b/CollectionThing/FastCollection.swift @@ -0,0 +1,213 @@ +// +// FastCollection.swift +// CollectionThing +// +// Created by Peter Livesey on 11/20/19. +// Copyright © 2019 Christopher Liscio. All rights reserved. +// + +import SwiftUI +import Combine + +fileprivate struct WrappedLayout { + /// The items to be laid out + let items: [Item] + + /// The (maximum) number of items to be placed in a row + let columns: Int + + /// A model representing the row of items + struct Row: Identifiable { + let id: [Item.ID] + let frame: CGRect + let items: ArraySlice + + init(frame: CGRect, items: ArraySlice) { + self.id = items.map { $0.id } + self.frame = frame + self.items = items + } + + func width(_ width: CGFloat) -> Row { + return Row(frame: CGRect(x: frame.origin.x, y: frame.origin.y, width: width, height: frame.size.height), items: items) + } + } + + let contentSize: CGSize + let rows: [Row] + + init(items: [Item], columns: Int, heightForItem: (Item) -> CGFloat) { + func rangeForRow(_ index: Int) -> Range { + return ((index * columns) ..< ((index + 1) * columns)).clamped(to: items.indices) + } + + var offset: CGFloat = 0 + let rowFrames: [CGRect] = items.map { item in + let y = offset + let height = heightForItem(item) + offset += height + return CGRect(x: 0, y: y, width: 1, height: height) + } + + let (quotient, remainder) = items.count.quotientAndRemainder(dividingBy: columns) + let rowCount = (remainder > 0) ? quotient + 1 : quotient + + self.items = items + self.rows = (0.. [Row] { + var returnValue = [Row]() + let minY = rect.minY + let maxY = rect.maxY + for row in rows { + if row.frame.maxY >= minY { + returnValue.append(row.width(rect.width)) + } + + if row.frame.minY > maxY { + // This is an optimization. If we've already gone far enough, there's no point in keeping checking. + return returnValue + } + } + + return returnValue + } +} + +public struct FastCollection: View { + public let viewForItem: (T) -> V + public let buffer: CGFloat + private let layout: WrappedLayout + + @State private var fixedBounds: CGRect = .zero + @State private var lastQueryRect: CGRect = .zero + + private var visibleRows: [WrappedLayout.Row] { + let queryRectWithBuffer = CGRect(x: lastQueryRect.minX, + y: lastQueryRect.minY - self.buffer, + width: lastQueryRect.width, + height: lastQueryRect.height + 2 * self.buffer) + return self.layout.rows(in: queryRectWithBuffer) + } + + + public init(items: [T], columns: Int = 1, buffer: CGFloat = 0, itemHeight: CGFloat, viewForItem: @escaping (T) -> V) { + self.init(items: items, columns: columns, buffer: buffer, heightForItem: { _ in itemHeight }, viewForItem: viewForItem) + } + + public init(items: [T], columns: Int = 1, buffer: CGFloat = 0, heightForItem: (T) -> CGFloat, viewForItem: @escaping (T) -> V) { + self.layout = WrappedLayout(items: items, columns: columns, heightForItem: heightForItem) + self.viewForItem = viewForItem + self.buffer = buffer + } + + public var body: some View { + // Calculate these once per body call as they could be expensive calls + let visibleRows = self.visibleRows + let visibleRowBounds = (visibleRows.first?.frame ?? .zero).union(visibleRows.last?.frame ?? .zero) + + return ScrollView { + ZStack(alignment: .top) { + MovingView() + .frame(height: 0) + + Color.clear + .frame( + width: layout.contentSize.width, + height: layout.contentSize.height + ) + .hidden() + + VStack(spacing: 0) { + ForEach(visibleRows) { row in + HStack(spacing: 0) { + ForEach(row.items) { item in + self.viewForItem(item) + } + } + } + } + .frame( + width: self.fixedBounds.width, + height: visibleRowBounds.height, + alignment: .topLeading + ) + .position( + x: self.fixedBounds.midX, + y: visibleRowBounds.midY + ) + } + } + .background(FixedView().edgesIgnoringSafeArea(.all)) + .onPreferenceChange(ViewFrames.self) { values in + let fixedBounds = values[.fixedView] ?? .zero + let movingBounds = values[.movingView] ?? .zero + let boundsDirty = fixedBounds != self.fixedBounds + if boundsDirty { + self.fixedBounds = values[.fixedView] ?? .zero + } + + #if os(iOS) + let visibleRect = CGRect( + x: movingBounds.origin.x, + y: (fixedBounds.origin.y - movingBounds.origin.y), + width: fixedBounds.width, + height: fixedBounds.height) + #else + let visibleRect = CGRect( + x: movingBounds.origin.x, + y: movingBounds.origin.y - fixedBounds.height, + width: fixedBounds.width, + height: fixedBounds.height) + #endif + + let queryRect = visibleRect.insetBy(dx: 0, dy: -(visibleRect.height / 8)) + + if boundsDirty || self.lastQueryRect.isEmpty || self.lastQueryRect.intersection(queryRect).height < (visibleRect.height * 1.2) { + self.lastQueryRect = queryRect + } + } + } + + private struct MovingView: View { + var body: some View { + GeometryReader { proxy in + Color.clear.hidden().preference(key: ViewFrames.self, value: [.movingView: proxy.frame(in: .global)]) + }.frame(height: 0) + } + } + + private struct FixedView: View { + var body: some View { + GeometryReader { proxy in + Color.clear.hidden().preference(key: ViewFrames.self, value: [.fixedView: proxy.frame(in: .global)]) + } + } + } +} + +// Since this view uses a static var, it cannot be contained within FastCollection +fileprivate struct ViewFrames: PreferenceKey { + enum ViewType: Int { + case movingView + case fixedView + } + + static var defaultValue: [ViewType:CGRect] = [:] + + static func reduce(value: inout [ViewType:CGRect], nextValue: () -> [ViewType:CGRect]) { + value.merge(nextValue(), uniquingKeysWith: { old, new in new }) + } + + typealias Value = [ViewType:CGRect] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..059da73 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Chris Liscio + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.