diff --git a/Sources/WaterfallGrid/Environment/GridSyle.swift b/Sources/WaterfallGrid/Environment/GridSyle.swift index 6b8bb79..a96ec16 100644 --- a/Sources/WaterfallGrid/Environment/GridSyle.swift +++ b/Sources/WaterfallGrid/Environment/GridSyle.swift @@ -13,6 +13,7 @@ struct GridSyle { let spacing: CGFloat let animation: Animation? + @MainActor var columns: Int { #if os(OSX) || os(tvOS) || targetEnvironment(macCatalyst) || os(visionOS) return columnsInLandscape diff --git a/Sources/WaterfallGrid/Preference/AnyHashableAndSendable.swift b/Sources/WaterfallGrid/Preference/AnyHashableAndSendable.swift new file mode 100644 index 0000000..2b85655 --- /dev/null +++ b/Sources/WaterfallGrid/Preference/AnyHashableAndSendable.swift @@ -0,0 +1,15 @@ +// +// Copyright © 2024 Paolo Leonardi. +// +// Licensed under the MIT license. See the LICENSE file for more info. +// + +import Foundation + +struct AnyHashableAndSendable: @unchecked Sendable, Hashable { + private let wrapped: AnyHashable + + init(_ wrapped: some Hashable & Sendable) { + self.wrapped = .init(wrapped) + } +} diff --git a/Sources/WaterfallGrid/Preference/ElementPreference.swift b/Sources/WaterfallGrid/Preference/ElementPreference.swift index 0a8f034..68aa6f5 100644 --- a/Sources/WaterfallGrid/Preference/ElementPreference.swift +++ b/Sources/WaterfallGrid/Preference/ElementPreference.swift @@ -6,15 +6,15 @@ import SwiftUI -struct ElementPreferenceData: Equatable { - let id: AnyHashable +struct ElementPreferenceData: Equatable, Sendable { + let id: AnyHashableAndSendable let size: CGSize } struct ElementPreferenceKey: PreferenceKey { typealias Value = [ElementPreferenceData] - static var defaultValue: [ElementPreferenceData] = [] + static let defaultValue: [ElementPreferenceData] = [] static func reduce(value: inout [ElementPreferenceData], nextValue: () -> [ElementPreferenceData]) { value.append(contentsOf: nextValue()) diff --git a/Sources/WaterfallGrid/Preference/PreferenceSetter.swift b/Sources/WaterfallGrid/Preference/PreferenceSetter.swift index 4bb9553..d9ae681 100644 --- a/Sources/WaterfallGrid/Preference/PreferenceSetter.swift +++ b/Sources/WaterfallGrid/Preference/PreferenceSetter.swift @@ -6,12 +6,12 @@ import SwiftUI -struct PreferenceSetter: View { - var id: ID +struct PreferenceSetter: View { + var id: AnyHashableAndSendable var body: some View { GeometryReader { geometry in Color.clear - .preference(key: ElementPreferenceKey.self, value: [ElementPreferenceData(id: AnyHashable(self.id), size: geometry.size)]) + .preference(key: ElementPreferenceKey.self, value: [ElementPreferenceData(id: self.id, size: geometry.size)]) } } } diff --git a/Sources/WaterfallGrid/WaterfallGrid.swift b/Sources/WaterfallGrid/WaterfallGrid.swift index 70dcc7d..ce0556a 100644 --- a/Sources/WaterfallGrid/WaterfallGrid.swift +++ b/Sources/WaterfallGrid/WaterfallGrid.swift @@ -8,7 +8,7 @@ import SwiftUI /// A container that presents items of variable heights arranged in a grid. @available(iOS 13, OSX 10.15, tvOS 13, visionOS 1, watchOS 6, *) -public struct WaterfallGrid: View where Data : RandomAccessCollection, Content : View, ID : Hashable { +public struct WaterfallGrid: View where Data : RandomAccessCollection, Content : View, ID : Hashable & Sendable { @Environment(\.gridStyle) private var style @Environment(\.scrollOptions) private var scrollOptions @@ -20,7 +20,7 @@ public struct WaterfallGrid: View where Data : RandomAccessCo @State private var loaded = false @State private var gridHeight: CGFloat = 0 - @State private var alignmentGuides = [AnyHashable: CGPoint]() { + @State private var alignmentGuides = [AnyHashableAndSendable: CGPoint]() { didSet { loaded = !oldValue.isEmpty } } @@ -29,12 +29,12 @@ public struct WaterfallGrid: View where Data : RandomAccessCo GeometryReader { geometry in self.grid(in: geometry) .onPreferenceChange(ElementPreferenceKey.self, perform: { preferences in - DispatchQueue.global(qos: .userInteractive).async { - let (alignmentGuides, gridHeight) = self.alignmentsAndGridHeight(columns: self.style.columns, - spacing: self.style.spacing, - scrollDirection: self.scrollOptions.direction, - preferences: preferences) - DispatchQueue.main.async { + Task.detached(priority: .userInitiated) { + let (alignmentGuides, gridHeight) = await self.alignmentsAndGridHeight(columns: self.style.columns, + spacing: self.style.spacing, + scrollDirection: self.scrollOptions.direction, + preferences: preferences) + await MainActor.run { self.alignmentGuides = alignmentGuides self.gridHeight = gridHeight } @@ -55,10 +55,10 @@ public struct WaterfallGrid: View where Data : RandomAccessCo self.content(element) .frame(width: self.scrollOptions.direction == .vertical ? columnWidth : nil, height: self.scrollOptions.direction == .horizontal ? columnWidth : nil) - .background(PreferenceSetter(id: element[keyPath: self.dataId])) - .alignmentGuide(.top, computeValue: { _ in self.alignmentGuides[element[keyPath: self.dataId]]?.y ?? 0 }) - .alignmentGuide(.leading, computeValue: { _ in self.alignmentGuides[element[keyPath: self.dataId]]?.x ?? 0 }) - .opacity(self.alignmentGuides[element[keyPath: self.dataId]] != nil ? 1 : 0) + .background(PreferenceSetter(id: AnyHashableAndSendable(element[keyPath: self.dataId]))) + .alignmentGuide(.top, computeValue: { _ in self.alignmentGuides[AnyHashableAndSendable(element[keyPath: self.dataId])]?.y ?? 0 }) + .alignmentGuide(.leading, computeValue: { _ in self.alignmentGuides[AnyHashableAndSendable(element[keyPath: self.dataId])]?.x ?? 0 }) + .opacity(self.alignmentGuides[AnyHashableAndSendable(element[keyPath: self.dataId])] != nil ? 1 : 0) } } .animation(self.loaded ? self.style.animation : nil, value: UUID()) @@ -66,9 +66,9 @@ public struct WaterfallGrid: View where Data : RandomAccessCo // MARK: - Helpers - func alignmentsAndGridHeight(columns: Int, spacing: CGFloat, scrollDirection: Axis.Set, preferences: [ElementPreferenceData]) -> ([AnyHashable: CGPoint], CGFloat) { + func alignmentsAndGridHeight(columns: Int, spacing: CGFloat, scrollDirection: Axis.Set, preferences: [ElementPreferenceData]) -> ([AnyHashableAndSendable: CGPoint], CGFloat) { var heights = Array(repeating: CGFloat(0), count: columns) - var alignmentGuides = [AnyHashable: CGPoint]() + var alignmentGuides = [AnyHashableAndSendable: CGPoint]() preferences.forEach { preference in if let minValue = heights.min(), let indexMin = heights.firstIndex(of: minValue) { diff --git a/Tests/WaterfallGridTests/WaterfallGridTests.swift b/Tests/WaterfallGridTests/WaterfallGridTests.swift index 8e7b3c8..c26bb4d 100644 --- a/Tests/WaterfallGridTests/WaterfallGridTests.swift +++ b/Tests/WaterfallGridTests/WaterfallGridTests.swift @@ -28,38 +28,38 @@ class WaterfallGridTests: XCTestCase { let spacing: CGFloat = 8 let scrollDirection = Axis.Set.vertical let preferences = [ - ElementPreferenceData(id: 0, size: CGSize(width: width, height: 100)), - ElementPreferenceData(id: 1, size: CGSize(width: width, height: 80)), - ElementPreferenceData(id: 2, size: CGSize(width: width, height: 60)), - ElementPreferenceData(id: 3, size: CGSize(width: width, height: 120)), - ElementPreferenceData(id: 4, size: CGSize(width: width, height: 30)) + ElementPreferenceData(id: AnyHashableAndSendable(0), size: CGSize(width: width, height: 100)), + ElementPreferenceData(id: AnyHashableAndSendable(1), size: CGSize(width: width, height: 80)), + ElementPreferenceData(id: AnyHashableAndSendable(2), size: CGSize(width: width, height: 60)), + ElementPreferenceData(id: AnyHashableAndSendable(3), size: CGSize(width: width, height: 120)), + ElementPreferenceData(id: AnyHashableAndSendable(4), size: CGSize(width: width, height: 30)) ] - let alignmentsOneColumn: [AnyHashable : CGPoint] = [ - 0: CGPoint(x: 0, y: 0), - 1: CGPoint(x: 0, y: -108), - 2: CGPoint(x: 0, y: -196), - 3: CGPoint(x: 0, y: -264), - 4: CGPoint(x: 0, y: -392) + let alignmentsOneColumn: [AnyHashableAndSendable : CGPoint] = [ + AnyHashableAndSendable(0): CGPoint(x: 0, y: 0), + AnyHashableAndSendable(1): CGPoint(x: 0, y: -108), + AnyHashableAndSendable(2): CGPoint(x: 0, y: -196), + AnyHashableAndSendable(3): CGPoint(x: 0, y: -264), + AnyHashableAndSendable(4): CGPoint(x: 0, y: -392) ] - let alignmentsTwoColumns: [AnyHashable : CGPoint] = [ - 0: CGPoint(x: 0, y: 0), - 1: CGPoint(x: -48, y: 0), - 2: CGPoint(x: -48, y: -88), - 3: CGPoint(x: 0, y: -108), - 4: CGPoint(x: -48, y: -156) + let alignmentsTwoColumns: [AnyHashableAndSendable : CGPoint] = [ + AnyHashableAndSendable(0): CGPoint(x: 0, y: 0), + AnyHashableAndSendable(1): CGPoint(x: -48, y: 0), + AnyHashableAndSendable(2): CGPoint(x: -48, y: -88), + AnyHashableAndSendable(3): CGPoint(x: 0, y: -108), + AnyHashableAndSendable(4): CGPoint(x: -48, y: -156) ] - let alignmentsThreeColumns: [AnyHashable : CGPoint] = [ - 0: CGPoint(x: 0, y: 0), - 1: CGPoint(x: -48, y: 0), - 2: CGPoint(x: -96, y: 0), - 3: CGPoint(x: -96, y: -68), - 4: CGPoint(x: -48, y: -88) + let alignmentsThreeColumns: [AnyHashableAndSendable : CGPoint] = [ + AnyHashableAndSendable(0): CGPoint(x: 0, y: 0), + AnyHashableAndSendable(1): CGPoint(x: -48, y: 0), + AnyHashableAndSendable(2): CGPoint(x: -96, y: 0), + AnyHashableAndSendable(3): CGPoint(x: -96, y: -68), + AnyHashableAndSendable(4): CGPoint(x: -48, y: -88) ] - let testCases: [ ([AnyHashable : CGPoint], CGFloat, Int, UInt) ] = [ + let testCases: [ ([AnyHashableAndSendable : CGPoint], CGFloat, Int, UInt) ] = [ // expectedAlignments | expectedGridHeight | columns | line (alignmentsOneColumn, 422.0, 1, #line), (alignmentsTwoColumns, 228.0, 2, #line), @@ -81,38 +81,38 @@ class WaterfallGridTests: XCTestCase { let spacing: CGFloat = 8 let scrollDirection = Axis.Set.horizontal let preferences = [ - ElementPreferenceData(id: 0, size: CGSize(width: 100, height: height)), - ElementPreferenceData(id: 1, size: CGSize(width: 80, height: height)), - ElementPreferenceData(id: 2, size: CGSize(width: 60, height: height)), - ElementPreferenceData(id: 3, size: CGSize(width: 120, height: height)), - ElementPreferenceData(id: 4, size: CGSize(width: 30, height: height)) + ElementPreferenceData(id: AnyHashableAndSendable(0), size: CGSize(width: 100, height: height)), + ElementPreferenceData(id: AnyHashableAndSendable(1), size: CGSize(width: 80, height: height)), + ElementPreferenceData(id: AnyHashableAndSendable(2), size: CGSize(width: 60, height: height)), + ElementPreferenceData(id: AnyHashableAndSendable(3), size: CGSize(width: 120, height: height)), + ElementPreferenceData(id: AnyHashableAndSendable(4), size: CGSize(width: 30, height: height)) ] - let alignmentsOneColumn: [AnyHashable : CGPoint] = [ - 0: CGPoint(x: 0, y: 0), - 1: CGPoint(x: -108, y: 0), - 2: CGPoint(x: -196, y: 0), - 3: CGPoint(x: -264, y: 0), - 4: CGPoint(x: -392, y: 0) + let alignmentsOneColumn: [AnyHashableAndSendable : CGPoint] = [ + AnyHashableAndSendable(0): CGPoint(x: 0, y: 0), + AnyHashableAndSendable(1): CGPoint(x: -108, y: 0), + AnyHashableAndSendable(2): CGPoint(x: -196, y: 0), + AnyHashableAndSendable(3): CGPoint(x: -264, y: 0), + AnyHashableAndSendable(4): CGPoint(x: -392, y: 0) ] - let alignmentsTwoColumns: [AnyHashable : CGPoint] = [ - 0: CGPoint(x: 0, y: 0), - 1: CGPoint(x: 0, y: -48), - 2: CGPoint(x: -88, y: -48), - 3: CGPoint(x: -108, y: 0), - 4: CGPoint(x: -156, y: -48) + let alignmentsTwoColumns: [AnyHashableAndSendable : CGPoint] = [ + AnyHashableAndSendable(0): CGPoint(x: 0, y: 0), + AnyHashableAndSendable(1): CGPoint(x: 0, y: -48), + AnyHashableAndSendable(2): CGPoint(x: -88, y: -48), + AnyHashableAndSendable(3): CGPoint(x: -108, y: 0), + AnyHashableAndSendable(4): CGPoint(x: -156, y: -48) ] - let alignmentsThreeColumns: [AnyHashable : CGPoint] = [ - 0: CGPoint(x: 0, y: 0), - 1: CGPoint(x: 0, y: -48), - 2: CGPoint(x: 0, y: -96), - 3: CGPoint(x: -68, y: -96), - 4: CGPoint(x: -88, y: -48) + let alignmentsThreeColumns: [AnyHashableAndSendable : CGPoint] = [ + AnyHashableAndSendable(0): CGPoint(x: 0, y: 0), + AnyHashableAndSendable(1): CGPoint(x: 0, y: -48), + AnyHashableAndSendable(2): CGPoint(x: 0, y: -96), + AnyHashableAndSendable(3): CGPoint(x: -68, y: -96), + AnyHashableAndSendable(4): CGPoint(x: -88, y: -48) ] - let testCases: [ ([AnyHashable : CGPoint], CGFloat, Int, UInt) ] = [ + let testCases: [ ([AnyHashableAndSendable : CGPoint], CGFloat, Int, UInt) ] = [ // expectedAlignments | expectedGridHeight | columns | line (alignmentsOneColumn, 422.0, 1, #line), (alignmentsTwoColumns, 228.0, 2, #line), @@ -134,38 +134,38 @@ class WaterfallGridTests: XCTestCase { let spacing: CGFloat = 0 let scrollDirection = Axis.Set.vertical let preferences = [ - ElementPreferenceData(id: 0, size: CGSize(width: width, height: 100)), - ElementPreferenceData(id: 1, size: CGSize(width: width, height: 80)), - ElementPreferenceData(id: 2, size: CGSize(width: width, height: 60)), - ElementPreferenceData(id: 3, size: CGSize(width: width, height: 120)), - ElementPreferenceData(id: 4, size: CGSize(width: width, height: 30)) + ElementPreferenceData(id: AnyHashableAndSendable(0), size: CGSize(width: width, height: 100)), + ElementPreferenceData(id: AnyHashableAndSendable(1), size: CGSize(width: width, height: 80)), + ElementPreferenceData(id: AnyHashableAndSendable(2), size: CGSize(width: width, height: 60)), + ElementPreferenceData(id: AnyHashableAndSendable(3), size: CGSize(width: width, height: 120)), + ElementPreferenceData(id: AnyHashableAndSendable(4), size: CGSize(width: width, height: 30)) ] - let alignmentsOneColumn: [AnyHashable : CGPoint] = [ - 0: CGPoint(x: 0, y: 0), - 1: CGPoint(x: 0, y: -100), - 2: CGPoint(x: 0, y: -180), - 3: CGPoint(x: 0, y: -240), - 4: CGPoint(x: 0, y: -360) + let alignmentsOneColumn: [AnyHashableAndSendable : CGPoint] = [ + AnyHashableAndSendable(0): CGPoint(x: 0, y: 0), + AnyHashableAndSendable(1): CGPoint(x: 0, y: -100), + AnyHashableAndSendable(2): CGPoint(x: 0, y: -180), + AnyHashableAndSendable(3): CGPoint(x: 0, y: -240), + AnyHashableAndSendable(4): CGPoint(x: 0, y: -360) ] - let alignmentsTwoColumns: [AnyHashable : CGPoint] = [ - 0: CGPoint(x: 0, y: 0), - 1: CGPoint(x: -40, y: 0), - 2: CGPoint(x: -40, y: -80), - 3: CGPoint(x: 0, y: -100), - 4: CGPoint(x: -40, y: -140) + let alignmentsTwoColumns: [AnyHashableAndSendable : CGPoint] = [ + AnyHashableAndSendable(0): CGPoint(x: 0, y: 0), + AnyHashableAndSendable(1): CGPoint(x: -40, y: 0), + AnyHashableAndSendable(2): CGPoint(x: -40, y: -80), + AnyHashableAndSendable(3): CGPoint(x: 0, y: -100), + AnyHashableAndSendable(4): CGPoint(x: -40, y: -140) ] - let alignmentsThreeColumns: [AnyHashable : CGPoint] = [ - 0: CGPoint(x: 0, y: 0), - 1: CGPoint(x: -40, y: 0), - 2: CGPoint(x: -80, y: 0), - 3: CGPoint(x: -80, y: -60), - 4: CGPoint(x: -40, y: -80) + let alignmentsThreeColumns: [AnyHashableAndSendable : CGPoint] = [ + AnyHashableAndSendable(0): CGPoint(x: 0, y: 0), + AnyHashableAndSendable(1): CGPoint(x: -40, y: 0), + AnyHashableAndSendable(2): CGPoint(x: -80, y: 0), + AnyHashableAndSendable(3): CGPoint(x: -80, y: -60), + AnyHashableAndSendable(4): CGPoint(x: -40, y: -80) ] - let testCases: [ ([AnyHashable : CGPoint], CGFloat, Int, UInt) ] = [ + let testCases: [ ([AnyHashableAndSendable : CGPoint], CGFloat, Int, UInt) ] = [ // expectedAlignments | expectedGridHeight | columns | line (alignmentsOneColumn, 390.0, 1, #line), (alignmentsTwoColumns, 220.0, 2, #line), @@ -187,38 +187,38 @@ class WaterfallGridTests: XCTestCase { let spacing: CGFloat = 0 let scrollDirection = Axis.Set.horizontal let preferences = [ - ElementPreferenceData(id: 0, size: CGSize(width: 100, height: height)), - ElementPreferenceData(id: 1, size: CGSize(width: 80, height: height)), - ElementPreferenceData(id: 2, size: CGSize(width: 60, height: height)), - ElementPreferenceData(id: 3, size: CGSize(width: 120, height: height)), - ElementPreferenceData(id: 4, size: CGSize(width: 30, height: height)) + ElementPreferenceData(id: AnyHashableAndSendable(0), size: CGSize(width: 100, height: height)), + ElementPreferenceData(id: AnyHashableAndSendable(1), size: CGSize(width: 80, height: height)), + ElementPreferenceData(id: AnyHashableAndSendable(2), size: CGSize(width: 60, height: height)), + ElementPreferenceData(id: AnyHashableAndSendable(3), size: CGSize(width: 120, height: height)), + ElementPreferenceData(id: AnyHashableAndSendable(4), size: CGSize(width: 30, height: height)) ] - let alignmentsOneColumn: [AnyHashable : CGPoint] = [ - 0: CGPoint(x: 0, y: 0), - 1: CGPoint(x: -100, y: 0), - 2: CGPoint(x: -180, y: 0), - 3: CGPoint(x: -240, y: 0), - 4: CGPoint(x: -360, y: 0) + let alignmentsOneColumn: [AnyHashableAndSendable : CGPoint] = [ + AnyHashableAndSendable(0): CGPoint(x: 0, y: 0), + AnyHashableAndSendable(1): CGPoint(x: -100, y: 0), + AnyHashableAndSendable(2): CGPoint(x: -180, y: 0), + AnyHashableAndSendable(3): CGPoint(x: -240, y: 0), + AnyHashableAndSendable(4): CGPoint(x: -360, y: 0) ] - let alignmentsTwoColumns: [AnyHashable : CGPoint] = [ - 0: CGPoint(x: 0, y: 0), - 1: CGPoint(x: 0, y: -40), - 2: CGPoint(x: -80, y:-40), - 3: CGPoint(x: -100, y: 0), - 4: CGPoint(x: -140, y: -40) + let alignmentsTwoColumns: [AnyHashableAndSendable : CGPoint] = [ + AnyHashableAndSendable(0): CGPoint(x: 0, y: 0), + AnyHashableAndSendable(1): CGPoint(x: 0, y: -40), + AnyHashableAndSendable(2): CGPoint(x: -80, y:-40), + AnyHashableAndSendable(3): CGPoint(x: -100, y: 0), + AnyHashableAndSendable(4): CGPoint(x: -140, y: -40) ] - let alignmentsThreeColumns: [AnyHashable : CGPoint] = [ - 0: CGPoint(x: 0, y: 0), - 1: CGPoint(x: 0, y: -40), - 2: CGPoint(x: 0, y: -80), - 3: CGPoint(x: -60, y: -80), - 4: CGPoint(x: -80, y: -40) + let alignmentsThreeColumns: [AnyHashableAndSendable : CGPoint] = [ + AnyHashableAndSendable(0): CGPoint(x: 0, y: 0), + AnyHashableAndSendable(1): CGPoint(x: 0, y: -40), + AnyHashableAndSendable(2): CGPoint(x: 0, y: -80), + AnyHashableAndSendable(3): CGPoint(x: -60, y: -80), + AnyHashableAndSendable(4): CGPoint(x: -80, y: -40) ] - let testCases: [ ([AnyHashable : CGPoint], CGFloat, Int, UInt) ] = [ + let testCases: [ ([AnyHashableAndSendable : CGPoint], CGFloat, Int, UInt) ] = [ // expectedAlignments | expectedGridHeight | columns | line (alignmentsOneColumn, 390.0, 1, #line), (alignmentsTwoColumns, 220.0, 2, #line),