У меня есть горизонтальный редактор SwiftUI (несколько «холстов», расположенных рядом в ScrollView). Когда пользователь перетаскивает выбранный объект и достигает левого/правого края видимой области, я хочу, чтобы ScrollView автоматически прокручивался плавно, чтобы перетаскивание могло продолжаться по холстам. Два требования:
Автопрокрутка плавная с ускорением по мере приближения пальца к краю.
Держите перетаскиваемый элемент визуально под пальцем во время автопрокрутки.
Я использую именованное координатное пространство, глобальное наложение для границы выделения и самоанализ UIScrollView для управления contentOffset. В основном это работает, но автопрокрутка может заикаться и (в зависимости от логики компенсации) перетаскиваемый элемент может на мгновение «выскользнуть» из-под пальца во время автопрокрутки.
Ниже приведен минимальный воспроизводимый пример, показывающий мой текущий подход. Он использует таймер на основе CADisplayLink для плавной непрерывной автоматической прокрутки с ускорением. Недостающий бит – это лучший способ сохранить положение элемента на одном уровне с пальцем во время перемещения представления прокрутки.
import SwiftUI
import Combine
import UIKit
import UniformTypeIdentifiers
struct ContentView: View {
@StateObject private var store = CanvasStore()
@State private var canvasSize: CGSize = .init(width: 300, height: 400)
@State private var dragStartItemPosition: CGPoint? = nil
@State private var isEditing: Bool = false
@State private var isCanvasEditing: Bool = false
let targetHeight: CGFloat = 150
@State private var draggedItem: CanvasData? = nil
@State private var scrollView: UIScrollView? = nil
@State private var autoScrollScheduled: Bool = false
var body: some View {
VStack {
let targetPreviewWidth: CGFloat = 150
// scale based on width
let scale: CGFloat = isCanvasEditing ? (targetPreviewWidth / canvasSize.width) : 1
let previewWidth = canvasSize.width * scale
let previewHeight = canvasSize.height * scale
ScrollView(.horizontal, showsIndicators: false) {
ZStack {
LazyHStack(spacing: 1) {
ForEach(store.canvasesArray, id: \.id) { item in
let i = store.canvasesArray.firstIndex(of: item) ?? 0
cellView(for: item, index: i, scale: scale, previewWidth: previewWidth, previewHeight: previewHeight, isDragging: draggedItem?.id == item.id)
}
}
// Global overlay during drag using ObjectViewBorder.
if let selected = store.selectedItem,
let info = store.indexForItem(id: selected.id),
store.canvasFrames.indices.contains(info.canvasIndex) {
let frame = store.canvasFrames[info.canvasIndex]
ObjectViewBorder(selectedItem: selected)
.offset(x: frame.minX, y: frame.minY)
.transition(.opacity)
}
}
.coordinateSpace(name: "carousel")
.background(HorizontalScrollViewIntrospector(scrollView: $scrollView))
// container height adapts to scaled canvas + tools
.frame(
height: isCanvasEditing
? (previewHeight + 60) // 60 = approx tools height, tweak
: canvasSize.height
)
.contentShape(Rectangle())
.gesture(
dragGesture,
including: (store.selectedItemID != nil && !isEditing) ? .gesture : .none
)
}
.environmentObject(store)
}
.onChange(of: isCanvasEditing) { newValue in
if !newValue {
draggedItem = nil
}
}
.frame(maxWidth: .infinity,maxHeight: .infinity)
.navigationBarBackButtonHidden(true)
.safeAreaInset(edge: .bottom) {
ZStack {
if isCanvasEditing {
HStack {
Button("Done") {
withAnimation(.spring(response: 0.3,
dampingFraction: 0.8)) {
isCanvasEditing = false
}
}
}
.frame(maxWidth: .infinity)
.background(.thinMaterial)
.transition(.move(edge: .bottom).combined(with: .opacity))
}else{
HStack {
Button("Edit") {
withAnimation(.spring(response: 0.3,
dampingFraction: 0.8)) {
isCanvasEditing = true
store.selectedItemID = nil
}
}
}
.frame(maxWidth: .infinity)
.background(.thinMaterial)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.8),
value: isCanvasEditing)
}
}
@ViewBuilder
private func cellView(for item: CanvasData, index: Int,scale:CGFloat,previewWidth:CGFloat,previewHeight:CGFloat, isDragging: Bool) -> some View {
VStack(spacing: 4) {
let cell = HStack(spacing: 1) {
ZStack {
// full-size canvas content
canvasCell(index: index)
.frame(width: canvasSize.width,
height: canvasSize.height)
.scaleEffect(scale, anchor: .center)
}
.frame(width: previewWidth,
height: previewHeight)
if index < store.canvasesArray.count - 1 {
Divider()
.frame(height: previewHeight)
}
}
cell
.scaleEffect(isDragging ? 1.06 : 1.0)
.shadow(color: .black.opacity(isDragging ? 0.35 : 0.15),
radius: isDragging ? 12 : 4,
x: 0,
y: isDragging ? 8 : 2)
.animation(.interpolatingSpring(stiffness: 260, damping: 16),
value: isDragging)
if isCanvasEditing {
canvasEditingTools(for: item,previewWidth: previewWidth,previewHeight: previewHeight)
}
Spacer(minLength: 0)
}.onDrop(
of: [UTType.text],
delegate: CanvasDropDelegate(
targetItem: item,
items: $store.canvasesArray,
draggedItem: $draggedItem
)
)
}
fileprivate func canvasEditingTools(
for item: CanvasData,
previewWidth: CGFloat,
previewHeight: CGFloat
) -> some View {
canvasEditingTools(for: item, previewWidth: previewWidth, previewHeight: previewHeight) {
EmptyView()
}
}
// Overload with optional preview via default EmptyView
fileprivate func canvasEditingTools
(
for item: CanvasData,
previewWidth: CGFloat,
previewHeight: CGFloat,
@ViewBuilder preview: () -> Preview = { EmptyView() }
) -> some View {
return HStack(spacing: 12) {
Image(systemName: "line.3.horizontal")
.font(.title3)
.foregroundColor(.black)
.padding(.vertical, 4)
.onDrag {
// Start drag only when this is pressed (still with system long-press)
if draggedItem == nil{
self.draggedItem = item
}else{
self.draggedItem = nil
}
return NSItemProvider(object: item.id.uuidString as NSString)
} preview: {
// Drag preview
preview()
.frame(width: previewWidth, height: previewHeight)
}
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
private var dragGesture: some Gesture {
DragGesture(minimumDistance: 2)
.onChanged { value in
guard let selectedID = store.selectedItemID,
let (canvasIndex, itemIndex) = store.indexForItem(id: selectedID)
else { return }
let item = store.canvasesArray[canvasIndex].items[itemIndex]
if dragStartItemPosition == nil {
dragStartItemPosition = item.position
}
guard let start = dragStartItemPosition else { return }
let t = value.translation
let newPos = CGPoint(
x: start.x + t.width,
y: start.y + t.height
)
store.isDraggingSelectedItem = true
store.canvasesArray[canvasIndex].items[itemIndex].position = newPos
// Auto-scroll when near edges
autoScrollIfNeeded(globalPoint: globalCenter(for: store.canvasesArray[canvasIndex].items[itemIndex]))
}
.onEnded { _ in
defer { dragStartItemPosition = nil }
guard let selectedID = store.selectedItemID,
let (fromCanvas, itemIndex) = store.indexForItem(id: selectedID)
else { return }
let item = store.canvasesArray[fromCanvas].items[itemIndex]
guard store.canvasFrames.indices.contains(fromCanvas) else { return }
let canvasFrame = store.canvasFrames[fromCanvas]
// convert local center to global center
let globalCenter = CGPoint(
x: canvasFrame.minX + item.position.x,
y: canvasFrame.minY + item.position.y
)
store.finishDrag(
for: selectedID,
fromCanvas: fromCanvas,
globalCenter: globalCenter
)
store.isDraggingSelectedItem = false
autoScrollScheduled = false
}
}
@ViewBuilder
private func canvasCell(index: Int) -> some View {
VStack(spacing: 6) {
CanvasView(canvasIndex: index)
.frame(width: canvasSize.width, height: canvasSize.height)
}
}
// MARK: - Helpers
private func globalCenter(for item: CanvasItem) -> CGPoint {
guard let info = store.indexForItem(id: item.id),
store.canvasFrames.indices.contains(info.canvasIndex) else {
return .zero
}
let frame = store.canvasFrames[info.canvasIndex]
return CGPoint(x: frame.minX + item.position.x,
y: frame.minY + item.position.y)
}
// MARK: - Auto-scroll logic
private func autoScrollIfNeeded(globalPoint: CGPoint) {
guard let sv = scrollView else { return }
// globalPoint is in the named "carousel" content coordinate space
let inset: CGFloat = 80
let minStep: CGFloat = 4
let maxStep: CGFloat = 28
let visibleMinX = sv.contentOffset.x
let visibleMaxX = visibleMinX + sv.bounds.width
let distLeft = globalPoint.x - visibleMinX
let distRight = visibleMaxX - globalPoint.x
var dx: CGFloat = 0
var needs = false
if distLeft < inset {
let closeness = max(0, (inset - distLeft) / inset) // 0..1
let eased = closeness * closeness // quadratic
let step = minStep + (maxStep - minStep) * eased
dx = -step
needs = true
} else if distRight < inset {
let closeness = max(0, (inset - distRight) / inset)
let eased = closeness * closeness
let step = minStep + (maxStep - minStep) * eased
dx = step
needs = true
}
if needs {
let maxOffsetX = max(0, sv.contentSize.width - sv.bounds.width)
var offset = sv.contentOffset
offset.x = min(max(offset.x + dx, 0), maxOffsetX)
if offset != sv.contentOffset {
sv.setContentOffset(offset, animated: false)
}
// keep scrolling while hovering near edge
if store.isDraggingSelectedItem && !autoScrollScheduled {
autoScrollScheduled = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0/60.0) {
autoScrollScheduled = false
if store.isDraggingSelectedItem, let selected = store.selectedItem {
let center = globalCenter(for: selected)
autoScrollIfNeeded(globalPoint: center)
}
}
}
}
}
}
struct CanvasDropDelegate: DropDelegate {
let targetItem: CanvasData
@Binding var items: [CanvasData]
@Binding var draggedItem: CanvasData?
func dropEntered(info: DropInfo) {
guard let draggedItem = draggedItem,
draggedItem != targetItem,
let fromIndex = items.firstIndex(of: draggedItem),
let toIndex = items.firstIndex(of: targetItem) else {
return
}
if items[toIndex] != draggedItem {
withAnimation(.default) {
items.move(fromOffsets: IndexSet(integer: fromIndex),
toOffset: toIndex > fromIndex ? toIndex + 1 : toIndex)
}
}
}
func performDrop(info: DropInfo) -> Bool {
// drop finished → clear dragged state
draggedItem = nil
return true
}
func dropUpdated(info: DropInfo) -> DropProposal? {
DropProposal(operation: .move)
}
}
import SwiftUI
struct CanvasView: View {
@EnvironmentObject var store: CanvasStore
let canvasIndex: Int
var body: some View {
GeometryReader { geo in
ZStack {
// Tap empty space inside this canvas → unselect item
Color.clear
.contentShape(Rectangle())
.onTapGesture {
store.selectedItemID = nil
}
let items: [CanvasItem] = (store.canvasesArray.indices.contains(canvasIndex) ? store.canvasesArray[canvasIndex].items : [])
ForEach(items) { item in
let isSelected = (store.selectedItemID == item.id)
Text(item.text)
.padding(8)
.position(item.position)
.onTapGesture {
store.selectedItemID = item.id
}
}
}
.onAppear {
if store.canvasFrames.indices.contains(canvasIndex) {
store.canvasFrames[canvasIndex] = geo.frame(in: .named("carousel"))
}
}
.onChange(of: geo.frame(in: .named("carousel"))) { newFrame in
if store.canvasFrames.indices.contains(canvasIndex) {
store.canvasFrames[canvasIndex] = newFrame
}
}
}
}
}
import Combine
import SwiftUI
@Observable
class CanvasItem: Identifiable, Codable {
let id = UUID()
var text: String
var position: CGPoint
var rotation: Angle = .zero
var width: CGFloat = 200.0
var height: CGFloat = 50.0
init(text: String = "test", position: CGPoint, rotation: Angle = .zero, width: CGFloat = 200.0, height: CGFloat = 50.0) {
self.text = text
self.position = position
self.rotation = rotation
self.width = width
self.height = height
}
}
// Optional but useful: Equatable for CanvasItem based on id
extension CanvasItem: Equatable {
static func == (lhs: CanvasItem, rhs: CanvasItem) -> Bool {
lhs.id == rhs.id
}
}
@Observable
class CanvasData: Identifiable, Codable {
let id = UUID()
var items: [CanvasItem]
init(items: [CanvasItem]) {
self.items = items
}
}
// Required: Equatable for CanvasData based on id
extension CanvasData: Equatable {
static func == (lhs: CanvasData, rhs: CanvasData) -> Bool {
lhs.id == rhs.id
}
}
@MainActor
final class CanvasStore: ObservableObject {
@Published var canvasesArray: [CanvasData] {
didSet { ensureCanvasFramesCount() }
}
@Published var canvasFrames: [CGRect] // global frames of each canvas
@Published var selectedItemID: CanvasItem.ID? // currently selected text
@Published var isDraggingSelectedItem: Bool = false
var selectedItem: CanvasItem? {
guard let id = selectedItemID else { return nil }
for canvas in canvasesArray {
if let item = canvas.items.first(where: { $0.id == id }) {
return item
}
}
return nil
}
init() {
let first = CanvasItem(text: "Drag me ➡️", position: CGPoint(x: 80, y: 80))
let initialCanvases: [CanvasData] = [
CanvasData(items: [first]),
CanvasData(items: []),
CanvasData(items: []),
CanvasData(items: []),
CanvasData(items: []),
CanvasData(items: []),
CanvasData(items: [])
]
self.canvasesArray = initialCanvases
self.canvasFrames = Array(repeating: .zero, count: initialCanvases.count)
self.selectedItemID = nil
}
func indexForItem(id: CanvasItem.ID) -> (canvasIndex: Int, itemIndex: Int)? {
for c in canvasesArray.indices {
if let i = canvasesArray[c].items.firstIndex(where: { $0.id == id }) {
return (c, i)
}
}
return nil
}
}
private extension CGRect {
var area: CGFloat { width * height }
}
extension CanvasStore {
private func ensureCanvasFramesCount() {
let target = canvasesArray.count
if canvasFrames.count == target { return }
if canvasFrames.count < target {
canvasFrames.append(contentsOf: Array(repeating: .zero, count: target - canvasFrames.count))
} else {
canvasFrames.removeLast(canvasFrames.count - target)
}
}
func finishDrag(for id: CanvasItem.ID, fromCanvas: Int, globalCenter: CGPoint) {
guard canvasesArray.indices.contains(fromCanvas),
let itemIndex = canvasesArray[fromCanvas].items.firstIndex(where: { $0.id == id }) else { return }
let item = canvasesArray[fromCanvas].items[itemIndex]
let size = CGSize(width: item.width, height: item.height)
guard size.width > 0, size.height > 0 else { return }
let itemRect = CGRect(
x: globalCenter.x - size.width / 2,
y: globalCenter.y - size.height / 2,
width: size.width,
height: size.height
)
// Find which canvas has the largest overlap
var bestCanvas: Int?
var bestOverlap: CGFloat = 0
for (index, frame) in canvasFrames.enumerated() {
let intersection = frame.intersection(itemRect)
let overlapArea = max(0, intersection.area)
if overlapArea > bestOverlap {
bestOverlap = overlapArea
bestCanvas = index
}
}
guard let targetIndex = bestCanvas, bestOverlap > 0 else { return }
// Move only if >= half of the item is inside target canvas
let halfArea = itemRect.area / 2
guard bestOverlap >= halfArea else { return }
if targetIndex == fromCanvas { return }
// Move item to target canvas
var movedItem = canvasesArray[fromCanvas].items.remove(at: itemIndex)
guard canvasFrames.indices.contains(targetIndex) else { return }
let targetFrame = canvasFrames[targetIndex]
let localCenter = CGPoint(
x: globalCenter.x - targetFrame.minX,
y: globalCenter.y - targetFrame.minY
)
movedItem.position = localCenter
canvasesArray[targetIndex].items.append(movedItem)
selectedItemID = movedItem.id // keep selection on the moved item
}
}
struct ObjectViewBorder: View {
@Bindable var selectedItem: CanvasItem
@State private var lastDragValue: CGSize = .zero
@State private var initialRotation: Angle = .zero
@State private var initialAngle: Angle = .zero
var body: some View {
ZStack {
// Base dashed border (doesn't block gestures)
RoundedRectangle(cornerRadius: 5)
.stroke(style: StrokeStyle(lineWidth: 1, dash: [5]))
.frame(width: selectedItem.width + 20, height: selectedItem.height + 20)
.position(selectedItem.position)
.allowsHitTesting(false)
}
.foregroundColor(.accentColor)
}
}
import SwiftUI
import UIKit
struct HorizontalScrollViewIntrospector: UIViewRepresentable {
@Binding var scrollView: UIScrollView?
func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
DispatchQueue.main.async { self.scrollView = findScrollView(from: view) }
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
DispatchQueue.main.async { self.scrollView = findScrollView(from: uiView) }
}
private func findScrollView(from view: UIView) -> UIScrollView? {
var v: UIView? = view
while let s = v?.superview {
if let sv = s as? UIScrollView { return sv }
v = s
}
return nil
}
}
Я пробовал:
Именованное координатное пространство для согласованной геометрии; вычисление видимого диапазона с помощью contentOffset.
самоанализ UIScrollView; установка contentOffset напрямую (без анимации).
Ускорение с использованием квадратичного замедления вблизи краев.
Предыдущая попытка корректировала локальный X элемента на тот же dx, что и шаг прокрутки, чтобы держать его под пальцем; визуально это работает, но я не уверен, что это правильный подход (он изменяет состояние модели во время прокрутки).
Я также попробовал поставить галочку по таймеру (asyncAfter); CADisplayLink стал более плавным.
Вопросы:
Какой шаблон в SwiftUI рекомендуется использовать, чтобы перетаскиваемый элемент визуально оставался под пальцем, пока представление прокрутки автоматически прокручивается?
Должен ли я корректировать локальное положение элемента с помощью дельты прокрутки (dx) при каждом такте или определять положение элемента непосредственно из глобальное местоположение пальца преобразуется в координаты содержимого (например, с помощью GeometryProxy + UIScrollView contentOffset)?
[*]Есть ли лучший способ обеспечить плавную автопрокрутку, чем вручную пошаговое управление contentOffset (например, ScrollViewReader, программное панорамирование UIScrollView или другая композиция жестов)?
У меня есть горизонтальный редактор SwiftUI (несколько «холстов», расположенных рядом в ScrollView). Когда пользователь перетаскивает выбранный объект и достигает левого/правого края видимой области, я хочу, чтобы ScrollView автоматически прокручивался плавно, чтобы перетаскивание могло продолжаться по холстам. Два требования: [list] [*]Автопрокрутка плавная с ускорением по мере приближения пальца к краю. [*]Держите перетаскиваемый элемент визуально под пальцем во время автопрокрутки. [/list] Я использую именованное координатное пространство, глобальное наложение для границы выделения и самоанализ UIScrollView для управления contentOffset. В основном это работает, но автопрокрутка может заикаться и (в зависимости от логики компенсации) перетаскиваемый элемент может на мгновение «выскользнуть» из-под пальца во время автопрокрутки. Ниже приведен минимальный воспроизводимый пример, показывающий мой текущий подход. Он использует таймер на основе CADisplayLink для плавной непрерывной автоматической прокрутки с ускорением. Недостающий бит – это лучший способ сохранить положение элемента на одном уровне с пальцем во время перемещения представления прокрутки. [code]import SwiftUI import Combine import UIKit import UniformTypeIdentifiers
struct ContentView: View {
@StateObject private var store = CanvasStore() @State private var canvasSize: CGSize = .init(width: 300, height: 400) @State private var dragStartItemPosition: CGPoint? = nil @State private var isEditing: Bool = false
@State private var isCanvasEditing: Bool = false let targetHeight: CGFloat = 150 @State private var draggedItem: CanvasData? = nil @State private var scrollView: UIScrollView? = nil @State private var autoScrollScheduled: Bool = false
var body: some View { VStack { let targetPreviewWidth: CGFloat = 150
// scale based on width let scale: CGFloat = isCanvasEditing ? (targetPreviewWidth / canvasSize.width) : 1 let previewWidth = canvasSize.width * scale let previewHeight = canvasSize.height * scale
ScrollView(.horizontal, showsIndicators: false) { ZStack { LazyHStack(spacing: 1) { ForEach(store.canvasesArray, id: \.id) { item in let i = store.canvasesArray.firstIndex(of: item) ?? 0 cellView(for: item, index: i, scale: scale, previewWidth: previewWidth, previewHeight: previewHeight, isDragging: draggedItem?.id == item.id) } }
// Global overlay during drag using ObjectViewBorder. if let selected = store.selectedItem, let info = store.indexForItem(id: selected.id), store.canvasFrames.indices.contains(info.canvasIndex) { let frame = store.canvasFrames[info.canvasIndex] ObjectViewBorder(selectedItem: selected) .offset(x: frame.minX, y: frame.minY) .transition(.opacity) } } .coordinateSpace(name: "carousel") .background(HorizontalScrollViewIntrospector(scrollView: $scrollView)) // container height adapts to scaled canvas + tools .frame( height: isCanvasEditing ? (previewHeight + 60) // 60 = approx tools height, tweak : canvasSize.height ) .contentShape(Rectangle()) .gesture( dragGesture, including: (store.selectedItemID != nil && !isEditing) ? .gesture : .none ) }
.environmentObject(store)
} .onChange(of: isCanvasEditing) { newValue in if !newValue { draggedItem = nil } } .frame(maxWidth: .infinity,maxHeight: .infinity)
fileprivate func canvasEditingTools( for item: CanvasData, previewWidth: CGFloat, previewHeight: CGFloat ) -> some View { canvasEditingTools(for: item, previewWidth: previewWidth, previewHeight: previewHeight) { EmptyView() } } // Overload with optional preview via default EmptyView fileprivate func canvasEditingTools ( for item: CanvasData, previewWidth: CGFloat, previewHeight: CGFloat, @ViewBuilder preview: () -> Preview = { EmptyView() } ) -> some View {
return HStack(spacing: 12) { Image(systemName: "line.3.horizontal") .font(.title3) .foregroundColor(.black) .padding(.vertical, 4) .onDrag { // Start drag only when this is pressed (still with system long-press)
private var dragGesture: some Gesture { DragGesture(minimumDistance: 2) .onChanged { value in guard let selectedID = store.selectedItemID, let (canvasIndex, itemIndex) = store.indexForItem(id: selectedID) else { return }
let item = store.canvasesArray[canvasIndex].items[itemIndex]
if dragStartItemPosition == nil { dragStartItemPosition = item.position } guard let start = dragStartItemPosition else { return }
let t = value.translation let newPos = CGPoint( x: start.x + t.width, y: start.y + t.height )
@Observable class CanvasItem: Identifiable, Codable { let id = UUID() var text: String var position: CGPoint var rotation: Angle = .zero var width: CGFloat = 200.0 var height: CGFloat = 50.0
// Optional but useful: Equatable for CanvasItem based on id extension CanvasItem: Equatable { static func == (lhs: CanvasItem, rhs: CanvasItem) -> Bool { lhs.id == rhs.id } }
@Observable class CanvasData: Identifiable, Codable { let id = UUID() var items: [CanvasItem] init(items: [CanvasItem]) { self.items = items } }
// Required: Equatable for CanvasData based on id extension CanvasData: Equatable { static func == (lhs: CanvasData, rhs: CanvasData) -> Bool { lhs.id == rhs.id } }
@MainActor final class CanvasStore: ObservableObject { @Published var canvasesArray: [CanvasData] { didSet { ensureCanvasFramesCount() } } @Published var canvasFrames: [CGRect] // global frames of each canvas @Published var selectedItemID: CanvasItem.ID? // currently selected text @Published var isDraggingSelectedItem: Bool = false
var selectedItem: CanvasItem? { guard let id = selectedItemID else { return nil } for canvas in canvasesArray { if let item = canvas.items.first(where: { $0.id == id }) { return item } } return nil }
func indexForItem(id: CanvasItem.ID) -> (canvasIndex: Int, itemIndex: Int)? { for c in canvasesArray.indices { if let i = canvasesArray[c].items.firstIndex(where: { $0.id == id }) { return (c, i) } } return nil } }
// Find which canvas has the largest overlap var bestCanvas: Int? var bestOverlap: CGFloat = 0
for (index, frame) in canvasFrames.enumerated() { let intersection = frame.intersection(itemRect) let overlapArea = max(0, intersection.area) if overlapArea > bestOverlap { bestOverlap = overlapArea bestCanvas = index } }
private func findScrollView(from view: UIView) -> UIScrollView? { var v: UIView? = view while let s = v?.superview { if let sv = s as? UIScrollView { return sv } v = s } return nil } } [/code] Я пробовал: [list] [*]Именованное координатное пространство для согласованной геометрии; вычисление видимого диапазона с помощью contentOffset. [*]самоанализ UIScrollView; установка contentOffset напрямую (без анимации). [*]Ускорение с использованием квадратичного замедления вблизи краев. [*]Предыдущая попытка корректировала локальный X элемента на тот же dx, что и шаг прокрутки, чтобы держать его под пальцем; визуально это работает, но я не уверен, что это правильный подход (он изменяет состояние модели во время прокрутки). [*]Я также попробовал поставить галочку по таймеру (asyncAfter); CADisplayLink стал более плавным. [/list] Вопросы: [list] [*]Какой шаблон в SwiftUI рекомендуется использовать, чтобы перетаскиваемый элемент визуально оставался под пальцем, пока представление прокрутки автоматически прокручивается?
Должен ли я корректировать локальное положение элемента с помощью дельты прокрутки (dx) при каждом такте или определять положение элемента непосредственно из глобальное местоположение пальца преобразуется в координаты содержимого (например, с помощью GeometryProxy + UIScrollView contentOffset)? [/list]
[*]Есть ли лучший способ обеспечить плавную автопрокрутку, чем вручную пошаговое управление contentOffset (например, ScrollViewReader, программное панорамирование UIScrollView или другая композиция жестов)?