How to position views relative to other views in different coordinate systems in SwiftUI
Asked Answered
N

1

6

I'm trying to implement a drag and drop functionality in my app using SwiftUI.

I create two circles which are located in two different HStacks. They do not share the same coordinate space.

The circle with the stroke is the target, the green filled circle is the object that will be dragged.

I was able to get their absolute positions using a GeometryReader inside an .overlay. I use this to detect if they are overlapping when the object circle is dragged over the target circle. This works.

When they don't overlap, the object circle will move back to its original position. When they do overlap the object circle is supposed to snap into place at the position of the target circle. Here is where I seem to have issues.

I am trying to set the new X and Y positions of the object circle by taking the: object circle local position - object circle global position + target circle global position.

    objectPosition = CGPoint(
        x: objectPosition.x - objectFrame.midX + targetFrame.midX,
        y: objectPosition.y - objectFrame.midY + targetFrame.midY
   )

I would assume this brings me to the target circle's coordinate space. But somehow it does not work.

So far I have been unable to find a reliable and simple way to convert coordinate spaces in SwiftUI. Using the workaround of GeometryReader inside an overlay at least gives me the right global positions. But I haven't found a way to use those positions to place views in a .global coordinate space as well.

If someone has an idea why my calculation of the coordinate space is wrong, or even knows a way to convert and position views relative to each other more easily I'd much appreciate it.

Here my code for a SwiftUI single view iOS app:

import SwiftUI

struct ContentView: View {
    
    @State private var isDragging: Bool = false
    
    @State private var objectDragOffset: CGSize = .zero
    @State private var objectPosition: CGPoint = .zero
    
    @State private var objectFrame: CGRect = .zero
    @State private var targetFrame: CGRect = .zero
    
    var body: some View {
        VStack {
            HStack {
                Circle()
                    .stroke(lineWidth: 3)
                    .fill(Color.blue)
                    .frame(width: 100.0, height: 100.0)
                    .overlay(
                        GeometryReader { targetGeometry in
                            Color(.clear)
                                .onAppear { targetFrame = targetGeometry.frame(in: .global) }
                        }
                    )
                    .position(CGPoint(x:180, y: 190))
            }.background(Color.yellow)
            HStack {
                Circle()
                    .foregroundColor(.green)
                    .frame(width: 100, height: 100, alignment: .center)
                    .overlay(
                        GeometryReader { objectGeometry in
                            Color(.clear)
                                .onAppear {
                                    objectFrame = objectGeometry.frame(in: .global) }
                        }
                    )
                    .position(objectPosition)
                    .offset( isDragging ? objectDragOffset : .zero)
                    .onAppear { objectPosition = CGPoint(x: 200, y: 250 ) }
                    .gesture(
                        DragGesture(coordinateSpace: .global)
                            .onChanged { drag in
                                isDragging = true
                                objectDragOffset = drag.translation
                            }
                            .onEnded { drag in
                                isDragging = false
                                
                                if targetFrame.contains(drag.location) {
                                    objectPosition = CGPoint(
                                        x: objectPosition.x - objectFrame.midX + targetFrame.midX,
                                        y: objectPosition.y - objectFrame.midY + targetFrame.midY
                                    )
                                } else {
                                    objectPosition = CGPoint(x: 200, y: 250 )
                                }
                            }
                    )
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Nakesha answered 23/11, 2020 at 16:25 Comment(1)
After some more testing, it think the issue could be related to the GeometryReader in the overlays and where the overlay is placed in the code. As using it this way is a workaround it seems either the overlay is working correctly or the GeometryReader depending where it's placed and when the .onAppear is called. I'm trying to figure out if this is the case.Nakesha
N
4

After a few more trying around I found a solution that works, also no matter how many views are created. Using a GeometryReader as the parent of each Circle I'm getting the .global and .local position of the object I want to drag. I subtract the global position from its local position and to this I add the global position of the target. That gives me the new absolute position of the object, its destination, in its local coordinate space. My code also has the drag and drop implemented as well as a ViewModifier for the Circle for convenience and future use. I'm using the two underlying CGRects of the Circles to test for intersection. It's important to note that the initial positions of the circle are also set using the GeometryReader

If this can be simplified please post your comment or answer.

import SwiftUI

struct ContentView: View {

    @State private var isDragging: Bool = false
    @State private var atTarget: Bool = false

    @State private var objectDragOffset: CGSize = .zero
    @State private var objectPosition: CGPoint = .zero
    @State private var objectGlobalPosition: CGPoint = .zero

    @State private var targetGlobalPosition: CGPoint = .zero
    @State private var newObjectPosition: CGPoint = .zero

    var body: some View {

        VStack {
            HStack {
                GeometryReader { geometry in
                    Circle()
                        .stroke(lineWidth: 3)
                        .fill(Color.blue)
                        .modifier(CircleModifier())
                        .position(CGPoint(x:geometry.frame(in: .local).midX, y: geometry.frame(in: .local).midY))
                        .onAppear() {
                            targetGlobalPosition = CGPoint(x:geometry.frame(in: .global).midX, y: geometry.frame(in: .global).midY)
                        }
                }
            }.background(Color.yellow)

            HStack {
                GeometryReader { geometry in
                    Circle()
                        .foregroundColor(.red)
                        .position(atTarget ? newObjectPosition : CGPoint(x:geometry.frame(in: .local).midX, y: geometry.frame(in: .local).midY))
                        .modifier(CircleModifier())
                        .onAppear() {
                            objectPosition = CGPoint(x:geometry.frame(in: .local).midX, y: geometry.frame(in: .local).midY)
                            objectGlobalPosition = CGPoint(x:geometry.frame(in: .global).midX, y: geometry.frame(in: .global).midY)
                        }
                        .offset(isDragging ? objectDragOffset : .zero)
                        .gesture(
                            DragGesture(coordinateSpace: .global)
                                .onChanged { drag in
                                    isDragging = true
                                    objectDragOffset = drag.translation

                                    newObjectPosition = CGPoint(
                                        x: objectPosition.x - objectGlobalPosition.x + targetGlobalPosition.x,
                                        y: objectPosition.y - objectGlobalPosition.y + targetGlobalPosition.y
                                    )
                                }
                                .onEnded { drag in
                                    isDragging = false

                                    let targetFrame = CGRect(origin: targetGlobalPosition, size: CircleModifier.frame)
                                    let objectFrame = CGRect(origin: objectGlobalPosition, size: CircleModifier.frame)
                                        .offsetBy(dx: drag.translation.width, dy: drag.translation.height)
                                        .insetBy(dx: CircleModifier.frame.width * 0.1, dy: CircleModifier.frame.height * 0.1)

                                    if targetFrame.intersects(objectFrame) {
                                        // Will check for the intersection of the rectangles, not the circles. See above insetBy adjustment to get a good middle ground.
                                        atTarget = true
                                    } else {
                                        atTarget = false
                                    }
                                }
                        )
                }
            }
        }
    }
}

struct CircleModifier: ViewModifier {
    static let frame = CGSize(width: 100.0, height: 100.0)

    func body(content: Content) -> some View {
        content
            .frame(width: CircleModifier.frame.width, height: CircleModifier.frame.height, alignment: .center)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Based on the above solution I created a modifier that uses an internal .offset to align views. Maybe someone finds this helpful:

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public extension View {
    /// Positions the center of this view at the specified point in the specified
    /// coordinate space using offset.
    ///
    /// Use the `openRelativeOffset(_ position:in:)` modifier to place the center of a view at a
    /// specific coordinate in the specified coordinate space using a
    /// CGPoint to specify the `x`
    /// and `y` position of the target CoordinateSpace defined by the Enum `coordinateSpace`
    /// This is not changing the position of the view by internally using an offset, other views using auto layout should not be affected.
    ///
    ///     Text("Position by passing a CGPoint() and CoordinateSpace")
    ///         .openRelativeOffset(CGPoint(x: 175, y: 100), in: .global)
    ///         .border(Color.gray)
    ///
    /// - Parameters
    ///   - position: The point in the target CoordinateSpace at which to place the center of this. Uses auto layout if nil.
    ///   view.
    ///   - in coordinateSpace: The target CoordinateSpace at which to place the center of this view.
    ///
    /// - Returns: A view that fixes the center of this view at `position` in `coordinateSpace` .
    func openRelativeOffset(_ position: CGPoint?, in coordinateSpace: CoordinateSpace) -> some View {
        modifier(OpenRelativeOffset(position: position, coordinateSpace: coordinateSpace))
    }
}

private struct OpenRelativeOffset: ViewModifier {

    var position: CGPoint?
    @State private var newPosition: CGPoint = .zero
    @State private var newOffset: CGSize = .zero

    let coordinateSpace: CoordinateSpace

    @State var localPosition: CGPoint = .zero
    @State var targetPosition: CGPoint = .zero

    func body(content: Content) -> some View {

        if let position = position {
            return AnyView(
                content
                    .offset(newOffset)
                    .background(
                        GeometryReader { geometry in
                            Color.clear
                                .onAppear {
                                    let localFrame = geometry.frame(in: .local)
                                    let otherFrame = geometry.frame(in: coordinateSpace)

                                    localPosition = CGPoint(x: localFrame.midX, y: localFrame.midY)

                                    targetPosition = CGPoint(x: otherFrame.midX, y: otherFrame.midY)
                                    newPosition.x = localPosition.x - targetPosition.x + position.x
                                    newPosition.y = localPosition.y - targetPosition.y + position.y

                                    newOffset = CGSize(width: newPosition.x - abs(localPosition.x), height: newPosition.y - abs(localPosition.y))
                                }
                        }
                    )
            )
        } else {
            return AnyView(
                content
            )
        }
    }
}

The source code is also available in this repository with another version that internally uses .position.

https://github.com/marcoboerner/OpenSwiftUIViews

Nakesha answered 5/1, 2021 at 17:59 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.