I am attempting to place an object (a view of a square) on the screen and then drag it immediately. What I have achieved is the following:
How can I achieve the functionality: Place object on screen and immediately start dragging? I am missing something here. My code:
ContentView with square placement logic:
struct ContentView: View {
let screenSize = UIScreen.main.bounds
@State private var squares: [SquareView] = []
@State private var offsets = [CGSize](repeating: .zero, count: 300)
var body: some View {
GeometryReader { geo in
ForEach(squares, id: \.self) { square in
square
.position(x: square.startXLocation, y: square.startYLocation)
}
.ignoresSafeArea()
}
.onTouch(perform: updateLocation)
.onAppear {
for i in 0...2 {
let xLocation = Double.random(in: 50...(screenSize.width - 150))
let yLocation = Double.random(in: 50...(screenSize.height - 150))
let square = SquareView(sideLength: 40, number: i, startXLocation: xLocation, startYLocation: yLocation)
squares.append(square)
}
}
}
func updateLocation(_ location: CGPoint, type: TouchType) {
var square = SquareView(sideLength: 50, number: Int.random(in: 20...99), startXLocation: location.x, startYLocation: location.y)
if type == .started {
squares.append(square)
square.startXLocation = location.x
square.startYLocation = location.y
}
if type == .moved {
let newSquare = squares.last!
newSquare.offset = CGSize(width: location.x - newSquare.startXLocation, height: location.y - newSquare.startYLocation)
}
if type == .ended {
// Don't need to do anything here
}
}
}
The squares that I place with the logic to drag on the screen:
struct SquareView: View, Hashable {
let colors: [Color] = [.green, .red, .blue, .yellow]
let sideLength: Double
let number: Int
var startXLocation: Double
var startYLocation: Double
@State private var squareColor: Color = .yellow
@State var startOffset: CGSize = .zero
@State var offset: CGSize = .zero
var body: some View {
ZStack{
Rectangle()
.frame(width: sideLength, height: sideLength)
.foregroundColor(squareColor)
.onAppear {
squareColor = colors.randomElement()!
}
Text("\(number)")
} // ZStack
.offset(offset)
.gesture(
DragGesture()
.onChanged { gesture in
offset.width = gesture.translation.width + startOffset.width
offset.height = gesture.translation.height + startOffset.height
}
.onEnded { value in
startOffset.width = value.location.x
startOffset.height = value.location.y
}
)
}
static func ==(lhs: SquareView, rhs: SquareView) -> Bool {
return lhs.number == rhs.number
}
func hash(into hasher: inout Hasher) {
hasher.combine(number)
}
}
The struct used to detect the touch location on the screen (not relevant for the actual question, but necessary to reconstruct the program). Adapted from code by Paul Hudson, hackingwithswift.com:
// The types of touches users want to be notified about
struct TouchType: OptionSet {
let rawValue: Int
static let started = TouchType(rawValue: 1 << 0)
static let moved = TouchType(rawValue: 1 << 1)
static let ended = TouchType(rawValue: 1 << 2)
static let all: TouchType = [.started, .moved, .ended]
}
// Our UIKit to SwiftUI wrapper view
struct TouchLocatingView: UIViewRepresentable {
// A closer to call when touch data has arrived
var onUpdate: (CGPoint, TouchType) -> Void
// The list of touch types to be notified of
var types = TouchType.all
// Whether touch information should continue after the user's finger has left the view
var limitToBounds = true
func makeUIView(context: Context) -> TouchLocatingUIView {
// Create the underlying UIView, passing in our configuration
let view = TouchLocatingUIView()
view.onUpdate = onUpdate
view.touchTypes = types
view.limitToBounds = limitToBounds
return view
}
func updateUIView(_ uiView: TouchLocatingUIView, context: Context) {
}
// The internal UIView responsible for catching taps
class TouchLocatingUIView: UIView {
// Internal copies of our settings
var onUpdate: ((CGPoint, TouchType) -> Void)?
var touchTypes: TouchType = .all
var limitToBounds = true
// Our main initializer, making sure interaction is enabled.
override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = true
}
// Just in case you're using storyboards!
required init?(coder: NSCoder) {
super.init(coder: coder)
isUserInteractionEnabled = true
}
// Triggered when a touch starts.
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
send(location, forEvent: .started)
}
// Triggered when an existing touch moves.
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
send(location, forEvent: .moved)
}
// Triggered when the user lifts a finger.
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
send(location, forEvent: .ended)
}
// Triggered when the user's touch is interrupted, e.g. by a low battery alert.
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
send(location, forEvent: .ended)
}
// Send a touch location only if the user asked for it
func send(_ location: CGPoint, forEvent event: TouchType) {
guard touchTypes.contains(event) else {
return
}
if limitToBounds == false || bounds.contains(location) {
onUpdate?(CGPoint(x: round(location.x), y: round(location.y)), event)
}
}
}
}
// A custom SwiftUI view modifier that overlays a view with our UIView subclass.
struct TouchLocater: ViewModifier {
var type: TouchType = .all
var limitToBounds = true
let perform: (CGPoint, TouchType) -> Void
func body(content: Content) -> some View {
content
.background(
TouchLocatingView(onUpdate: perform, types: type, limitToBounds: limitToBounds)
)
// .overlay(
// TouchLocatingView(onUpdate: perform, types: type, limitToBounds: limitToBounds)
// )
}
}
// A new method on View that makes it easier to apply our touch locater view.
extension View {
func onTouch(type: TouchType = .all, limitToBounds: Bool = true, perform: @escaping (CGPoint, TouchType) -> Void) -> some View {
self.modifier(TouchLocater(type: type, limitToBounds: limitToBounds, perform: perform))
}
}
// Finally, here's some example code you can try out.
struct ContentView1: View {
var body: some View {
VStack {
Text("This will track all touches, inside bounds only.")
.padding()
.background(.red)
.onTouch(perform: updateLocation)
Text("This will track all touches, ignoring bounds – you can start a touch inside, then carry on moving it outside.")
.padding()
.background(.blue)
.onTouch(limitToBounds: false, perform: updateLocation)
Text("This will track only starting touches, inside bounds only.")
.padding()
.background(.green)
.onTouch(type: .started, perform: updateLocation)
}
}
func updateLocation(_ location: CGPoint, type: TouchType) {
print(location, type)
}
}
A possible approach is to handle drag and creation in "area" (background container), while "item" views are just rendered at the place where needed.
Find below a simplified demo (used Xcode 13.2 / iOS 15.2), see also comments in code snapshot.
Note: tap detection in already "existed" item is an exercise for you.
extension CGPoint: Identifiable { // just a helper for demo
public var id: String { "\(x)-\(y)" }
}
struct TapAndDragDemo: View {
@State private var points: [CGPoint] = [] // << persistent
@State private var point: CGPoint? // << current
@GestureState private var dragState: CGSize = CGSize.zero
var body: some View {
Color.clear.overlay( // << area
Group {
ForEach(points) { // << stored `items`
Rectangle()
.frame(width: 24, height: 24)
.position(x: $0.x, y: $0.y)
}
if let curr = point { // << active `item`
Rectangle().fill(Color.red)
.frame(width: 24, height: 24)
.position(x: curr.x, y: curr.y)
}
}
)
.contentShape(Rectangle()) // << make area tappable
.gesture(DragGesture(minimumDistance: 0.0)
.updating($dragState) { drag, state, _ in
state = drag.translation
}
.onChanged {
point = $0.location // track drag current
}
.onEnded {
points.append($0.location) // push to stored
point = nil
}
)
}
}