Ich habe einen SwiftUI-Horizontaleditor (mehrere „Leinwände“, die nebeneinander in einer ScrollView angeordnet sind). Wenn der Benutzer ein ausgewähltes Objekt zieht und
den linken/rechten Rand des sichtbaren Bereichs erreicht, möchte ich, dass die ScrollView automatisch einen reibungslosen Bildlauf durchführt, damit das Ziehen über die Leinwände hinweg fortgesetzt werden kann. Zwei
Anforderungen:
Autoscroll reibungslos mit Beschleunigung, wenn sich der Finger der Kante nähert.
Das gezogene Element beim automatischen Scrollen sichtbar unter dem Finger halten.
Ich verwende einen benannten Koordinatenraum, eine globale Überlagerung für den Auswahlrahmen und UIScrollView-Selbstbeobachtung, um den ContentOffset zu steuern. Meistens funktioniert es, aber der automatische Bildlauf kann stottern und (abhängig von der Kompensationslogik) kann das gezogene Element während des automatischen Bildlaufs kurzzeitig unter dem Finger wegrutschen.
Unten ist ein minimal reproduzierbares Beispiel, das meinen aktuellen Ansatz zeigt. Es verwendet einen CADisplayLink-basierten Timer für einen reibungslosen, kontinuierlichen Autoscroll mit Beschleunigung. Das fehlende Bit ist die beste Vorgehensweise, um die Position des Elements mit dem Finger auszurichten, während sich die Scroll-Ansicht bewegt.
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
}
}
Was ich versucht habe
Benannter Koordinatenraum für konsistente Geometrie; Berechnen des sichtbaren Bereichs mit contentOffset.
UIScrollView Introspection; contentOffset direkt festlegen (keine Animation).
Beschleunigung durch quadratische Beschleunigung in der Nähe von Kanten.
Bei einem früheren Versuch wurde das lokale X des Elements um denselben dx wie beim Bildlaufschritt angepasst, um es unter dem Finger zu halten; Es funktioniert visuell, aber ich bin mir nicht sicher, ob dies der richtige Ansatz ist (es ändert den Modellstatus während des Scrollens).
Ich habe es auch mit einem Timer-Tick (asyncAfter) versucht; CADisplayLink ist flüssiger.
Fragen
Was ist das empfohlene Muster in SwiftUI, um das gezogene Element visuell unter dem Finger zu halten, während die Scroll-Ansicht programmgesteuert
autoscrolling ist?
Soll ich die lokale Position des Elements durch das Scroll-Delta (dx) bei jedem Tick anpassen oder die Position des Elements direkt daraus ableiten? Die
globale Position des Fingers in Inhaltskoordinaten umgewandelt (z. B. mit GeometryProxy + UIScrollView contentOffset)?
[*]Gibt es eine bessere Möglichkeit, einen reibungslosen automatischen Bildlauf zu steuern, als den ContentOffset manuell schrittweise zu steuern (z. B. ScrollViewReader, UIScrollView pan
programmgesteuert oder eine andere Gestenzusammensetzung)?
Ich habe einen SwiftUI-Horizontaleditor (mehrere „Leinwände“, die nebeneinander in einer ScrollView angeordnet sind). Wenn der Benutzer ein ausgewähltes Objekt zieht und den linken/rechten Rand des sichtbaren Bereichs erreicht, möchte ich, dass die ScrollView automatisch einen reibungslosen Bildlauf durchführt, damit das Ziehen über die Leinwände hinweg fortgesetzt werden kann. Zwei Anforderungen: [list] [*]Autoscroll reibungslos mit Beschleunigung, wenn sich der Finger der Kante nähert. [*]Das gezogene Element beim automatischen Scrollen sichtbar unter dem Finger halten. [/list] Ich verwende einen benannten Koordinatenraum, eine globale Überlagerung für den Auswahlrahmen und UIScrollView-Selbstbeobachtung, um den ContentOffset zu steuern. Meistens funktioniert es, aber der automatische Bildlauf kann stottern und (abhängig von der Kompensationslogik) kann das gezogene Element während des automatischen Bildlaufs kurzzeitig unter dem Finger wegrutschen. Unten ist ein minimal reproduzierbares Beispiel, das meinen aktuellen Ansatz zeigt. Es verwendet einen CADisplayLink-basierten Timer für einen reibungslosen, kontinuierlichen Autoscroll mit Beschleunigung. Das fehlende Bit ist die beste Vorgehensweise, um die Position des Elements mit dem Finger auszurichten, während sich die Scroll-Ansicht bewegt. [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] Was ich versucht habe [list] [*]Benannter Koordinatenraum für konsistente Geometrie; Berechnen des sichtbaren Bereichs mit contentOffset. [*]UIScrollView Introspection; contentOffset direkt festlegen (keine Animation). [*]Beschleunigung durch quadratische Beschleunigung in der Nähe von Kanten. [*]Bei einem früheren Versuch wurde das lokale X des Elements um denselben dx wie beim Bildlaufschritt angepasst, um es unter dem Finger zu halten; Es funktioniert visuell, aber ich bin mir nicht sicher, ob dies der richtige Ansatz ist (es ändert den Modellstatus während des Scrollens). [*]Ich habe es auch mit einem Timer-Tick (asyncAfter) versucht; CADisplayLink ist flüssiger. [/list] Fragen [list] [*]Was ist das empfohlene Muster in SwiftUI, um das gezogene Element visuell unter dem Finger zu halten, während die Scroll-Ansicht programmgesteuert autoscrolling ist?
Soll ich die lokale Position des Elements durch das Scroll-Delta (dx) bei jedem Tick anpassen oder die Position des Elements direkt daraus ableiten? Die globale Position des Fingers in Inhaltskoordinaten umgewandelt (z. B. mit GeometryProxy + UIScrollView contentOffset)? [/list]
[*]Gibt es eine bessere Möglichkeit, einen reibungslosen automatischen Bildlauf zu steuern, als den ContentOffset manuell schrittweise zu steuern (z. B. ScrollViewReader, UIScrollView pan programmgesteuert oder eine andere Gestenzusammensetzung)?
Ich habe einen SwiftUI-Horizontaleditor (mehrere „Leinwände“, die nebeneinander in einer ScrollView angeordnet sind). Wenn der Benutzer ein ausgewähltes Objekt zieht und den linken/rechten Rand des...
Ich habe eine Ionic Angular-Anwendung, bei der das Scrollen unter iOS vollständig funktioniert (Safari, Firefox, Edge – alle WebKit-basiert). Desktop-Browser funktionieren einwandfrei. Die...
Ich möchte den Bildlauf deaktivieren, wenn ich ein Popup-Div habe (z. B. einen Fotogalerie-Viewer oder ähnliches), aber ich möchte nicht, dass der Bildlauf für das untergeordnete Element deaktiviert...
Ich bin ziemlich neu in Swift und Swiftui. Ich erstelle meine erste App, um eine Galerie mit einigen zusätzlichen Funktionen zu sortieren.
Alles funktioniert gut für iPad Air M1, iPhone 15 Pro,...
Ich habe ein Sprite in einem 2D -Handyspiel in Einheit und versuche, diesen Sprite mit meinem Finger zu berühren und zu ziehen, damit sich das Sprite genau dort bewegen sollte, wo der Finger auf dem...