Skip to content

Instantly share code, notes, and snippets.

@ZekeSnider
Created March 1, 2025 07:52
Show Gist options
  • Save ZekeSnider/11839addeb2dde50437a0bf71c59c89a to your computer and use it in GitHub Desktop.
Save ZekeSnider/11839addeb2dde50437a0bf71c59c89a to your computer and use it in GitHub Desktop.
Custom SwiftUI list that includes drag to select functionality.
import Foundation
import SwiftUI
// MARK: - Models
struct Lyric: Identifiable {
let id: Int
let text: String
}
struct Lyrics {
var lyrics: [Lyric]
}
// MARK: - View Model
@Observable
class LyricsViewModel {
var lyrics: Lyrics
var selectedLyricIndexes: Set<Int> = []
init(lyrics: Lyrics) {
self.lyrics = lyrics
}
func updateLyricSelections(_ newSelection: [Int]) {
selectedLyricIndexes = Set(newSelection)
}
}
// MARK: - Preference Data Structures
struct LinePreferenceData: Equatable {
let index: Int
let minY: Double
let maxY: Double
let globalMinY: Double
let globalMaxY: Double
init(index: Int, bounds: CGRect, globalBounds: CGRect) {
self.index = index
self.minY = bounds.minY
self.maxY = bounds.maxY
self.globalMinY = globalBounds.minY
self.globalMaxY = globalBounds.maxY
}
}
struct LinePreferenceKey: PreferenceKey {
typealias Value = [LinePreferenceData]
static var defaultValue: [LinePreferenceData] = []
static func reduce(value: inout [LinePreferenceData], nextValue: () -> [LinePreferenceData]) {
value.append(contentsOf: nextValue())
}
}
// MARK: - Views
struct LyricsSelectionView: View {
@State var viewModel: LyricsViewModel
@GestureState private var isDragging: Bool = false
@State private var isSelecting: Bool = false
@State private var isDraggingSelected: Bool? = nil
@Binding var lineData: [LinePreferenceData]
@State var pendingSelection: Set<Int> = Set()
@State var isScrolling = false
var proxy: ScrollViewProxy?
enum ScrollDirection {
case up, down
}
var drag: some Gesture {
LongPressGesture(minimumDuration: 0.5)
.sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .named("container")))
.updating($isDragging, body: { value, state, transaction in
switch value {
case .first(true):
break
case .second(_, let drag):
guard let start = drag?.startLocation else { return }
let end = drag?.location ?? start
if isDraggingSelected == nil {
let dragStartIndex = getStartIndex(from: start.y)
isDraggingSelected = dragStartIndex.map { viewModel.selectedLyricIndexes.contains($0) } ?? false
}
handleDragChange(start: start, end: end)
default:
return
}
})
.onEnded { value in
switch value {
case .first(true):
// Long press succeeded
isSelecting = true
case .second(true, _):
// Drag ended
updateSelectionRange()
isSelecting = false
isDraggingSelected = nil
default:
break
}
}
}
private func getBackgroundColor(index: Int) -> some View {
if (pendingSelection.contains(index)) {
if isDraggingSelected ?? false {
return Color.gray.opacity(0.6)
} else {
return Color.blue.opacity(0.8)
}
}
else if viewModel.selectedLyricIndexes.contains(index) {
return Color.blue.opacity(0.2)
} else {
return Color.clear
}
}
var body: some View {
Group {
ForEach(viewModel.lyrics.lyrics) { lyric in
Button(action: {
lyricTapped(lyric: lyric)
}) {
Text(lyric.text)
.font(.headline)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.id(lyric.id)
.padding([.leading, .trailing], 15)
.padding([.top, .bottom], 15)
.buttonStyle(.plain)
.background(getBackgroundColor(index: lyric.id))
.contentShape(Rectangle())
.background() {
GeometryReader { geometry in
Rectangle()
.fill(Color.clear)
.preference(key: LinePreferenceKey.self,
value: [LinePreferenceData(index: lyric.id,
bounds: geometry.frame(in: .named("container")),
globalBounds: geometry.frame(in: .global))])
}
}
}
}
.highPriorityGesture(drag)
.onAppear {
if let proxy, let firstSelectedId = viewModel.selectedLyricIndexes.first {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
withAnimation {
proxy.scrollTo(firstSelectedId, anchor: .center)
}
}
}
}
}
private func handleDragChange(start: CGPoint, end: CGPoint) {
let minY = min(start.y, end.y)
let maxY = max(start.y, end.y)
// Clear the pending selection before recalculating
pendingSelection.removeAll()
// Find all lines that intersect with the drag range
let selectedLines = lineData.filter { line in
return !(line.maxY < minY || line.minY > maxY)
}
let currentLine = lineData.first { line in
return end.y >= line.minY && end.y <= line.maxY
}
pendingSelection = Set(selectedLines.map { $0.index })
if let currentLine {
handleAutoScroll(currentY: currentLine.globalMaxY, index: currentLine.index)
}
}
func lyricTapped(lyric: Lyric) {
if viewModel.selectedLyricIndexes.contains(lyric.id) {
viewModel.updateLyricSelections(Array(viewModel.selectedLyricIndexes).filter({$0 != lyric.id}))
} else {
viewModel.updateLyricSelections(Array(viewModel.selectedLyricIndexes) + [lyric.id])
}
}
func updateSelectionRange() {
if (isDraggingSelected ?? false) {
viewModel.updateLyricSelections(Array(viewModel.selectedLyricIndexes).filter({!pendingSelection.contains($0)}))
} else {
let newItems = pendingSelection.filter({!viewModel.selectedLyricIndexes.contains($0)})
viewModel.updateLyricSelections(Array(viewModel.selectedLyricIndexes) + Array(newItems))
}
pendingSelection = Set()
}
private func handleAutoScroll(currentY: CGFloat, index: Int) {
guard !isScrolling else { return }
// Get the geometry of the scroll view or container
let screenHeight = UIScreen.main.bounds.height
if currentY < 175 {
// Near top, scroll up
startAutoScroll(.up, index: index)
} else if currentY > screenHeight - 100 {
// Near bottom, scroll down
startAutoScroll(.down, index: index)
}
}
private func startAutoScroll(_ direction: ScrollDirection, index: Int) {
guard (!isScrolling) else { return }
isScrolling = true
withAnimation(.linear(duration: 1)) {
proxy?.scrollTo(getScrollIndex(direction, index: index), anchor: getScrollAnchor(direction))
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
isScrolling = false
}
}
private func getScrollIndex(_ direction: ScrollDirection, index: Int) -> Int {
switch direction {
case .down:
return [index + 3, viewModel.lyrics.lyrics.count].min()!
case .up:
return [index - 3, 0].max()!
}
}
private func getScrollAnchor(_ direction: ScrollDirection) -> UnitPoint {
switch direction {
case .down:
return .trailing
case .up:
return .leading
}
}
private func getStartIndex(from value: CGFloat) -> Int? {
return lineData.first(where: { line in
value >= line.minY && value <= line.maxY
})?.index
}
}
struct LyricsSelectionDemoView: View {
@State var viewModel: LyricsViewModel
@State private var lineData: [LinePreferenceData] = []
var body: some View {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 0) {
LyricsSelectionView(viewModel: viewModel,
lineData: $lineData,
proxy: proxy)
}
.scrollTargetLayout()
.cornerRadius(10)
.padding([.leading, .trailing], 10)
.onPreferenceChange(LinePreferenceKey.self) { value in
lineData = value
}
.coordinateSpace(name: "container")
}
.frame(maxHeight: UIScreen.main.bounds.height)
}
.navigationTitle("Select Lyrics")
}
}
// MARK: - Preview Data
extension LyricsViewModel {
static var sample: LyricsViewModel {
let lyrics = Lyrics(lyrics: [
Lyric(id: 0, text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit"),
Lyric(id: 1, text: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua"),
Lyric(id: 2, text: "Ut enim ad minim veniam, quis nostrud exercitation ullamco"),
Lyric(id: 3, text: "Laboris nisi ut aliquip ex ea commodo consequat"),
Lyric(id: 4, text: "Duis aute irure dolor in reprehenderit in voluptate"),
Lyric(id: 5, text: "Velit esse cillum dolore eu fugiat nulla pariatur"),
Lyric(id: 6, text: "Excepteur sint occaecat cupidatat non proident"),
Lyric(id: 7, text: "Sunt in culpa qui officia deserunt mollit anim id est laborum"),
Lyric(id: 8, text: "Curabitur pretium tincidunt lacus nulla gravida orci"),
Lyric(id: 9, text: "Nullam varius, turpis et commodo pharetra est eros bibendum elit"),
Lyric(id: 10, text: "Nec luctus magna felis sollicitudin mauris"),
Lyric(id: 11, text: "Integer in mauris eu nibh euismod gravida"),
Lyric(id: 12, text: "Duis ac tellus et risus vulputate vehicula"),
Lyric(id: 13, text: "Donec lobortis risus a elit fringilla pharetra"),
Lyric(id: 14, text: "Maecenas sed diam eget risus varius blandit sit amet"),
Lyric(id: 15, text: "Suspendisse in orci enim tristique massa rhoncus"),
Lyric(id: 16, text: "Proin venenatis ligula eget lacinia feugiat"),
Lyric(id: 17, text: "Fusce fermentum odio nec arcu viverra tempor"),
Lyric(id: 18, text: "Vestibulum dapibus nunc ac augue pellentesque"),
Lyric(id: 19, text: "Sed lectus vestibulum mattis ullamcorper velit"),
Lyric(id: 20, text: "Nam at tortor in tellus interdum sagittis"),
Lyric(id: 21, text: "Mauris ultrices eros in cursus turpis massa tincidunt"),
Lyric(id: 22, text: "Etiam rhoncus euismod lacus vel ornare"),
Lyric(id: 23, text: "Vivamus aliquet elit ac nisl vestibulum faucibus"),
Lyric(id: 24, text: "Cras ultricies mi eu turpis hendrerit fringilla")
])
return LyricsViewModel(lyrics: lyrics)
}
}
// MARK: - Preview
struct PreviewView: View {
var body: some View {
NavigationView {
LyricsSelectionDemoView(viewModel: LyricsViewModel.sample)
}
}
}
#Preview {
PreviewView()
}
@ZekeSnider
Copy link
Author

A full writeup on this view is available here: https://zeke.dev/customizing-swiftui-list-selection/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment