Created
March 1, 2025 07:52
-
-
Save ZekeSnider/11839addeb2dde50437a0bf71c59c89a to your computer and use it in GitHub Desktop.
Custom SwiftUI list that includes drag to select functionality.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
A full writeup on this view is available here: https://zeke.dev/customizing-swiftui-list-selection/