Drag to select multiple views in SwiftUI (Written in Swift 5 / iOS 15)
This wrapper view allows you to select multiple views in SwiftUI. You provide the view frames using a GeometryReader
and the functionality to support selecting and deselecting objects. This wrapper takes care of the rest.
//
// DragSelectContainerView.swift
//
// Created by Dhruv Sringari on 11/1/21.
//
import SwiftUI
struct DragFrame {
var frame: CGRect
var lastDragID: UUID
}
// This has to be a reference type!
class DragFrames<Object: Hashable> {
var dictionary: [Object: DragFrame] = [:]
}
/**
A container that supports drag to select on arbitrary frames that the content provides.
The biggest problem that I ran into when creating this view is that
I needed to be able to read and write data during view updates. The only way that I could find
to determine the runtime frames of objects created in SwiftUI is through GeometryReader which
can provide frames of objects during a view update.
So how do I store these frames inside a view update? I can't use @State since that behavior
is undefined and I can't define a var in the struct and write to it because that would
be mutating self which is not allowed.
The solution is to use a reference type inside a struct. This allows you to update the
reference object's value without updating the struct's value.
*/
struct DragSelectContainerView<Object: Hashable, Content: View, CoordinateSpaceName: Hashable> : View {
typealias SetFrameFunc = (Object, CGRect) -> Void
/// Coordinate space that should be used to calculate points
let coordinateSpaceName: CoordinateSpaceName
/// Content for children views that also provides frames to the container
var content: (@escaping SetFrameFunc) -> Content
/// Should return whether the given object is selected
var isSelected: (Object) -> Bool
/// Triggers selection for an object
var select: (Object) -> Void
// Mutable data inside a struct that can be referenced through self!
// Could also use any other reference type like NSDictionary for this.
private let _dragFrames = DragFrames<Object>()
private var frameDictionary: Dictionary<Object, DragFrame> {
get {
_dragFrames.dictionary
}
// Magic
nonmutating set {
_dragFrames.dictionary = newValue
}
}
@State var dragID = UUID()
@State var dragIsSelecting: Bool?
var body: some View {
content(self.updateFrame(_:frame:))
.simultaneousGesture(dragGesture)
}
func updateFrame(_ object: Object, frame: CGRect) {
frameDictionary[object] = DragFrame(frame: frame, lastDragID: UUID())
}
/**
This gesture will select frames only once per drag. If the first frame is selected
by the drag, then drag to select only works on non-selected frames. If the first frame
was un-selected, then drag to select only works on selected frames.
*/
var dragGesture: some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .named(coordinateSpaceName))
.onChanged { value in
let point = value.location
if let kvp = frameDictionary.first(where: { $1.frame.contains(point) && $1.lastDragID != dragID}) {
let object = kvp.key
if isSelected(object) == dragIsSelecting {
return
}
if dragIsSelecting == nil {
dragIsSelecting = !isSelected(object)
}
frameDictionary[object]!.lastDragID = dragID
select(object)
}
}
.onEnded { _ in
dragID = UUID()
dragIsSelecting = nil
}
}
}
Here's an example of it in use:
VStack {
DragSelectContainerView(coordinateSpaceName: coordinateSpaceName) { setFrame in
ForEach(0..<10, id: \.self) { index in
Rectangle()
.frame(width: 100, height: 50)
.overlay {
GeometryReader { geo -> Color in
let frame = geo.frame(in: .named(coordinateSpaceName))
setFrame(index, dayViewFrame)
return Color.clear
}
}
}
} isSelected: { index in
someViewModel.isSelected(index)
} select: { day in
someViewModel.select(index)
}
}
.coordinateSpace(coordinateSpaceName)