Плавная горизонтальная автопрокрутка при перетаскивании вблизи краев (держите объект под пальцем)IOS

Программируем под IOS
Ответить
Anonymous
 Плавная горизонтальная автопрокрутка при перетаскивании вблизи краев (держите объект под пальцем)

Сообщение Anonymous »

У меня есть горизонтальный редактор 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 или другая композиция жестов)?


Подробнее здесь: https://stackoverflow.com/questions/798 ... der-finger
Ответить

Быстрый ответ

Изменение регистра текста: 
Смайлики
:) :( :oops: :roll: :wink: :muza: :clever: :sorry: :angel: :read: *x)
Ещё смайлики…
   
К этому ответу прикреплено по крайней мере одно вложение.

Если вы не хотите добавлять вложения, оставьте поля пустыми.

Максимально разрешённый размер вложения: 15 МБ.

Вернуться в «IOS»