Skip to content

Instantly share code, notes, and snippets.

@druvv
Last active March 5, 2024 16:56
Show Gist options
  • Save druvv/30b8ebe786f4a9dc8ea5d5fc50db7223 to your computer and use it in GitHub Desktop.
Save druvv/30b8ebe786f4a9dc8ea5d5fc50db7223 to your computer and use it in GitHub Desktop.
Drag to select multiple views in SwiftUI

DragSelectContainerView

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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment