I'm working on a macOS app with SwiftUI that should be able to create, arrange and delete geometric shapes on screen. The creation and dragging of shapes already works pretty well using a context menu.
import SwiftUI
class Canvas: ObservableObject {
#Published var nodes: [Node] = []
func addNode(position: CGPoint) -> Void {
nodes.append(Node(id: UUID(), position: position))
}
}
struct CanvasView: View {
#ObservedObject var canvas = Canvas()
var body: some View {
ZStack {
Color(red: 0.9, green: 0.9, blue: 0.8)
.contextMenu {
Button( action: {
self.canvas.addNode(position: CGPoint(x: 400, y: 400))
} )
{ Text("Add Node ...") }
}
ForEach(canvas.nodes) {node in
NodeView(node: node)
}
}
}
}
class Node: Identifiable, ObservableObject {
#Published var id: UUID
#Published var position: CGPoint
#Published var positionProxy: CGPoint
init (id: UUID, position: CGPoint) {
self.id = id
self.position = position
self.positionProxy = position
}
}
struct NodeView: View {
#ObservedObject var node: Node
init(node: Node) {
self.node = node
}
var draggingNode: some Gesture {
DragGesture(coordinateSpace: .global)
.onChanged { value in
self.node.position.x = value.translation.width + self.node.positionProxy.x;
self.node.position.y = -value.translation.height + self.node.positionProxy.y
}
.onEnded { value in
self.node.position.x = value.translation.width + self.node.positionProxy.x;
self.node.position.y = -value.translation.height + self.node.positionProxy.y;
self.node.positionProxy = self.node.position
}
}
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(Color.white)
.frame(width: 100, height: 100)
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(lineWidth: 1)
.fill(Color.gray)
)
.position(node.position)
.gesture(draggingNode)
}
}
My problem is that all created shapes appear at the same predefined location
position: CGPoint(x: 400, y: 400)
on screen and I have to move each of them manually to its intended position. I'm looking for a way to track the cursor position during right click or the context menu position to use it as node position and be able to write something like
self.canvas.addNode(position: cursorPosition)
instead of
self.canvas.addNode(position: CGPoint(x: 400, y: 400))
Is there any functionality in Swift, preferably in SwiftUI that solves my issue?
It is currently not possible to detect mouse click location in SwiftUI. A workaround for your issue may be to use a composition of SwiftUI Gesture's.
Here is code that you can use to detect a (simple or double) left-click location:
import SwiftUI
struct ClickGesture: Gesture {
let count: Int
let coordinateSpace: CoordinateSpace
typealias Value = SimultaneousGesture<TapGesture, DragGesture>.Value
init(count: Int = 1, coordinateSpace: CoordinateSpace = .local) {
precondition(count > 0, "Count must be greater than or equal to 1.")
self.count = count
self.coordinateSpace = coordinateSpace
}
var body: SimultaneousGesture<TapGesture, DragGesture> {
TapGesture(count: count)
.simultaneously(with: DragGesture(minimumDistance: 0, coordinateSpace: coordinateSpace))
}
func onEnded(perform action: #escaping (CGPoint) -> Void) -> some Gesture {
ClickGesture(count: count, coordinateSpace: coordinateSpace)
.onEnded { (value: Value) -> Void in
guard value.first != nil else { return }
guard let startLocation = value.second?.startLocation else { return }
guard let endLocation = value.second?.location else { return }
guard ((startLocation.x-1)...(startLocation.x+1)).contains(endLocation.x),
((startLocation.y-1)...(startLocation.y+1)).contains(endLocation.y) else { return }
action(startLocation)
}
}
}
extension View {
func onClickGesture(
count: Int,
coordinateSpace: CoordinateSpace = .local,
perform action: #escaping (CGPoint) -> Void
) -> some View {
gesture(ClickGesture(count: count, coordinateSpace: coordinateSpace)
.onEnded(perform: action)
)
}
func onClickGesture(
count: Int,
perform action: #escaping (CGPoint) -> Void
) -> some View {
onClickGesture(count: count, coordinateSpace: .local, perform: action)
}
func onClickGesture(
perform action: #escaping (CGPoint) -> Void
) -> some View {
onClickGesture(count: 1, coordinateSpace: .local, perform: action)
}
}
You can use it in a very similar fashion as onTapGesture() or TapGesture:
struct MyView: View {
var body: some View {
Rectangle()
.frame(width: 600, height: 400)
.onClickGesture(count: 2) { location in
print("Double tap at location \(location)")
}
}
}
You can additionally specify a CoordinateSpace.
Related
I want to custom Slider in SwiftUI. Just something like this.
I tried Slider with GeometryReader but it isn't working.
//MARK: Left - Right Balance
GeometryReader { geo in
VStack {
Text("\(String(format: "%.2f", balanceVolume))")
HStack {
Text("L")
Slider(value: $balanceVolume, in: minValue...maxValue, step: 0.1) {editing in
print("editing", editing)
isEditing = editing
if !editing {
player.pan = Float(balanceVolume)
}
}
.tint(.none)
.accentColor(.gray)
Text("R")
}
}
.padding(20)
}
Thank you you all.
I have created a simple custom slider, I hope it helps
Output:
Use:
struct slider: View {
#State var sliderPosition: Float = 50
var body: some View {
SliderView(value: $sliderPosition, bounds: 1...100).padding(.all)
}
}
Code:
struct SliderView: View {
let currentValue: Binding<Float>
let sliderBounds: ClosedRange<Int>
public init(value: Binding<Float>, bounds: ClosedRange<Int>) {
self.currentValue = value
self.sliderBounds = bounds
}
var body: some View {
GeometryReader { geomentry in
sliderView(sliderSize: geomentry.size)
}
}
#ViewBuilder private func sliderView(sliderSize: CGSize) -> some View {
let sliderViewYCenter = sliderSize.height / 2
let sliderViewXCenter = sliderSize.width / 2
ZStack {
RoundedRectangle(cornerRadius: 2)
.fill(Color.gray)
.frame(height: 3)
ZStack {
let sliderBoundDifference = sliderBounds.count
let stepWidthInPixel = CGFloat(sliderSize.width) / CGFloat(sliderBoundDifference)
let thumbLocation = CGFloat(currentValue.wrappedValue) * stepWidthInPixel
// Path between starting point to thumb
lineBetweenThumbs(from: .init(x: sliderViewXCenter, y: sliderViewYCenter), to: .init(x: thumbLocation, y: sliderViewYCenter))
// Thumb Handle
let thumbPoint = CGPoint(x: thumbLocation, y: sliderViewYCenter)
thumbView(position: thumbPoint, value: Float(currentValue.wrappedValue))
.highPriorityGesture(DragGesture().onChanged { dragValue in
let dragLocation = dragValue.location
let xThumbOffset = min(dragLocation.x, sliderSize.width)
let newValue = Float(sliderBounds.lowerBound / sliderBounds.upperBound) + Float(xThumbOffset / stepWidthInPixel)
if newValue > Float(sliderBounds.lowerBound) && newValue < Float(sliderBounds.upperBound + 1) {
currentValue.wrappedValue = newValue
}
})
}
}
}
#ViewBuilder func lineBetweenThumbs(from: CGPoint, to: CGPoint) -> some View {
Path { path in
path.move(to: from)
path.addLine(to: to)
}.stroke(Color.blue, lineWidth: 4)
}
#ViewBuilder func thumbView(position: CGPoint, value: Float) -> some View {
ZStack {
Text(String(round(value)))
.font(.headline)
.offset(y: -20)
Circle()
.frame(width: 24, height: 24)
.foregroundColor(.accentColor)
.shadow(color: Color.black.opacity(0.16), radius: 8, x: 0, y: 2)
.contentShape(Rectangle())
}
.position(x: position.x, y: position.y)
}
}
Here is my attempt to implement this functionality, I also tried to solve it through UIKit, it worked, but I ran into problems with dynamically changing the content of SwiftUI, which was inside UIScrollView. More precisely, the problem was in changing the height of the container
https://imgur.com/a/6du73pt
import SwiftUI
struct ScrollViewOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = .zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct ContentView: View {
#State private var offset: CGFloat = 300
var body: some View {
ZStack {
Color.yellow.ignoresSafeArea()
ScrollView(.vertical) {
ForEach(0..<100, id: \.self) { _ in
Color.red
.frame(width: 250, height: 125, alignment: .center)
}
.overlay(
GeometryReader { proxy in
let offset = proxy.frame(in: .named("scroll")).minY
Color.clear.preference(key: ScrollViewOffsetPreferenceKey.self, value: offset)
.frame(width: 0, height: 0, alignment: .center)
})
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
if value >= 0 {
offset = value + 300
}
}
.gesture(DragGesture()
.onChanged({ value in
print("scrooll")
print(value)
})
)
}
.offset(y: offset)
.gesture(DragGesture(minimumDistance: 25, coordinateSpace: .local)
.onChanged({ value in
offset = value.translation.height + 300
}))
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Below is an example of how you can lock the ScrollView when you are at the top, and then allow the DragGesture to operate instead of scroll. I removed your PreferenceKey as it was not necessary. I also used frame reader to determine where in the scroll view the top cell was. Code is extensively commented.
struct ScrollViewWithPulldown: View {
#State private var offset: CGFloat = 300
#State private var scrollEnabled = true
#State private var cellRect: CGRect = .zero
// if the top of the cell is in view, origin.y will be greater than or equal to zero
var topInView: Bool {
cellRect.origin.y >= 0
}
var body: some View {
ZStack {
Color.yellow.ignoresSafeArea()
ScrollView {
ForEach(0..<100, id: \.self) { id in
Color.red
.id(id)
.frame(width: 250, height: 125, alignment: .center)
// This is inspired by https://www.fivestars.blog/articles/swiftui-share-layout-information/
.copyFrame(in: .named("scroll"), to: $cellRect)
.onChange(of: cellRect) { _ in
if id == 0 { // insure the first view however you need to
if topInView {
scrollEnabled = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
scrollEnabled = true
}
} else {
scrollEnabled = true
}
}
}
}
}
.disabled(!scrollEnabled)
.coordinateSpace(name: "scroll")
}
.offset(y: offset)
.gesture(DragGesture()
.onChanged({ value in
// Scrolling down
if value.translation.height > 0 && topInView {
scrollEnabled = false
print("scroll locked")
print(value)
} else { // Scrolling up
scrollEnabled = true
print("scroll up")
print(value)
}
})
.onEnded({ _ in
scrollEnabled = true
})
)
}
}
A view extension inspired by FiveStar Blog:
extension View {
func readFrame(in space: CoordinateSpace, onChange: #escaping (CGRect) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: FrameInPreferenceKey.self, value: geometryProxy.frame(in: space))
}
)
.onPreferenceChange(FrameInPreferenceKey.self, perform: onChange)
}
func copyFrame(in space: CoordinateSpace, to binding: Binding<CGRect>) -> some View {
self.readFrame(in: space) { frame in
binding.wrappedValue = frame
}
}
}
I want to know if user did release the drag gesture inside View or out side, for this reason I just worked for local and it is working, I wanted finish for global, but I saw that I would be need to read the parent Size, the location and the size of child also some math work to know if the tap release was inside or out side the view, So I was not sure if there is a simpler way for this, that is why asked to know, the current view is just a simple Rec, but it would needed more math work if it was Circle or what I should do with a custom path Shape? I cannot hard coded multiple if for a custom path, which that condition would not usable for deferent custom path! So what is the logical and best way for this job?
PS: My focus is not finding answer for global coordinateSpace, I can do it by myself, but that would not useful if my view was Circle, or a custom path! I want find out a basic and general way for using to all cases, instead finding answer just for special condition.
struct ContentView: View {
#State private var isPressing: Bool = Bool()
let frameOfView: CGSize = CGSize(width: 300.0, height: 300.0)
var body: some View {
Color.red
.overlay(Color.yellow.frame(width: frameOfView.width, height: frameOfView.height).gesture(gesture))
}
private var gesture: some Gesture {
DragGesture(minimumDistance: 0.0, coordinateSpace: .local)
.onEnded() { value in
print("isInside =", isInside(frame: frameOfView, location: value.location, coordinateSpace: .local))
}
}
func isInside(frame: CGSize, location: CGPoint, coordinateSpace: CoordinateSpace) -> Bool {
if (coordinateSpace.isLocal) {
return (location.x >= 0.0) && (location.y >= 0.0) && (location.x <= frame.width) && (location.y <= frame.height)
}
else if (coordinateSpace.isGlobal) {
return false // under edit!
}
else {
return false // under edit!
}
}
}
You could pass in the Shape of the view you are using, and use that to determine the path for the shape of the view. You can then test if the last point dragged was inside or outside of this shape.
This is usually just a Rectangle(), aka a rectangular view, so in my example there is even a convenience initializer if you don't want to provide this every time.
Code:
struct TapReleaseDetector<ContentShape: Shape, Content: View>: View {
typealias TapAction = (Bool) -> Void
private let shape: ContentShape
private let content: () -> Content
private let action: TapAction
#State private var path: Path?
init(shape: ContentShape, #ViewBuilder content: #escaping () -> Content, action: #escaping TapAction) {
self.shape = shape
self.content = content
self.action = action
}
init(#ViewBuilder content: #escaping () -> Content, action: #escaping TapAction) where ContentShape == Rectangle {
self.init(shape: Rectangle(), content: content, action: action)
}
var body: some View {
content()
.background(
GeometryReader { geo in
Color.clear.onAppear {
path = shape.path(in: geo.frame(in: .local))
}
}
)
.gesture(gesture)
}
private var gesture: some Gesture {
DragGesture(minimumDistance: 0.0, coordinateSpace: .local)
.onEnded { drag in
guard let path = path else { return }
action(path.contains(drag.location))
}
}
}
Example usage:
struct ContentView: View {
#State private var result: Bool?
#State private var opacity: Double = 0
#State private var currentId = UUID()
private var resultText: String? {
if let result = result {
return result ? "Inside" : "Outside"
} else {
return nil
}
}
var body: some View {
VStack(spacing: 30) {
Text(resultText ?? " ")
.font(.title)
.opacity(opacity)
TapReleaseDetector(shape: Circle()) {
Circle()
.fill(Color.red)
.frame(width: 300, height: 300)
} action: { isInside in
result = isInside
opacity = 1
withAnimation(.easeOut(duration: 1)) {
opacity = 0
}
let tempId = UUID()
currentId = tempId
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
guard tempId == currentId else { return }
result = nil
}
}
Text("Recent: \(resultText ?? "None")")
}
}
}
Result:
lately I have been trying to create a pull to (refresh, load more) swiftUI Scroll View !!, inspired by https://cocoapods.org/pods/SwiftPullToRefresh
I was struggling to get the offset and the size of the content. but now I am struggling to get the event when the user releases the scroll view to finish the UI.
here is my current code:
struct PullToRefresh2: View {
#State var offset : CGPoint = .zero
#State var contentSize : CGSize = .zero
#State var scrollViewRect : CGRect = .zero
#State var items = (0 ..< 50).map { "Item \($0)" }
#State var isTopRefreshing = false
#State var isBottomRefreshing = false
var top : CGFloat {
return self.offset.y
}
private var bottomLocation : CGFloat {
if contentSize.height >= scrollViewRect.height {
return self.contentSize.height + self.top - self.scrollViewRect.height + 32
}
return top + 32
}
private var shouldTopRefresh : Bool {
return self.top > 80
}
private var shouldBottomRefresh : Bool {
return self.bottomLocation < -80 + 32
}
func watchOffset() -> Binding<CGPoint> {
return .init(get: {
return self.offset
},set: {
print("watched : offset= \($0)")
self.offset = $0
})
}
private func computeOffset() -> CGFloat {
if isTopRefreshing {
print("OFFSET: isTopRefreshing")
return 32
} else if isBottomRefreshing {
if (contentSize.height+32) < scrollViewRect.height {
print("OFFSET: isBottomRefreshing 1")
return top
} else if scrollViewRect.height > contentSize.height {
print("OFFSET: isBottomRefreshing 2")
return 32 - (scrollViewRect.height - contentSize.height)
} else {
print("OFFSET: isBottomRefreshing 3")
return scrollViewRect.height - contentSize.height - 32
}
}
print("OFFSET: fall back->\(top)")
return top
}
func watchScrollViewRect() -> Binding<CGRect> {
return .init(get: {
return self.scrollViewRect
},set: {
print("watched : scrollViewRect= \($0)")
self.scrollViewRect = $0
})
}
func watchContentSize() -> Binding<CGSize> {
return .init(get: {
return self.contentSize
},set: {
print("watched : contentSize= \($0)")
self.contentSize = $0
})
}
func newDragGuesture() -> some Gesture {
return DragGesture()
.onChanged { _ in
print("> drag changed")
}
.onEnded { _ in
DispatchQueue.main.async {
print("> drag ended")
self.isTopRefreshing = self.shouldTopRefresh
self.isBottomRefreshing = self.shouldTopRefresh
withAnimation {
self.offset = CGPoint.init(x: self.offset.x, y: self.computeOffset())
}
}
}
}
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
VStack {
Button(action: { self.presentationMode.wrappedValue.dismiss() }) {
Text("Back")
}
ZStack {
OffsetScrollView(.vertical, showsIndicators: true,
offset: self.watchOffset(),
contentSize: self.watchContentSize(),
scrollViewFrame: self.watchScrollViewRect())
{
VStack {
ForEach(self.items, id: \.self) { item in
HStack {
Text("\(item)")
.font(.system(Font.TextStyle.title))
.fontWeight(.regular)
//.frame(width: geo.size.width)
//.background(Color.blue)
.padding(.horizontal, 8)
Spacer()
}
//.background(Color.red)
.padding(.bottom, 8)
}
}//.background(Color.clear)
}.edgesIgnoringSafeArea(.horizontal)
.background(Color.red)
//.simultaneousGesture(self.newDragGuesture())
VStack {
ArrowShape()
.stroke(style: StrokeStyle.init(lineWidth: 2, lineCap: .round, lineJoin: .round, miterLimit: 0, dash: [], dashPhase: 0))
.fill(Color.black)
.frame(width: 12, height: 16)
.padding(.all, 2)
//.animation(nil)
.rotationEffect(.degrees(self.shouldTopRefresh ? -180 : 0))
.animation(.linear(duration: 0.2))
.transformEffect(.init(translationX: 0, y: self.top - 32))
.animation(nil)
.opacity(self.isTopRefreshing ? 0 : 1)
Spacer()
ArrowShape()
.stroke(style: StrokeStyle.init(lineWidth: 2, lineCap: .round, lineJoin: .round, miterLimit: 0, dash: [], dashPhase: 0))
.fill(Color.black)
.frame(width: 12, height: 16)
.padding(.all, 2)
//.animation(nil)
.rotationEffect(.degrees(self.shouldBottomRefresh ? 0 : -180))
.animation(.linear(duration: 0.2))
.transformEffect(.init(translationX: 0, y: self.bottomLocation))
.animation(nil)
.opacity(self.isBottomRefreshing ? 0 : 1)
}
// Color.init(.sRGB, white: 0.2, opacity: 0.7)
//
// .simultaneousGesture(self.newDragGuesture())
}
.clipped()
.clipShape(Rectangle())
Text("Offset: \(String(describing: self.offset))")
Text("contentSize: \(String(describing: self.contentSize))")
Text("scrollViewRect: \(String(describing: self.scrollViewRect))")
}
}
}
//https://zacwhite.com/2019/scrollview-content-offsets-swiftui/
public struct OffsetScrollView<Content>: View where Content : View {
/// The content of the scroll view.
public var content: Content
/// The scrollable axes.
///
/// The default is `.vertical`.
public var axes: Axis.Set
/// If true, the scroll view may indicate the scrollable component of
/// the content offset, in a way suitable for the platform.
///
/// The default is `true`.
public var showsIndicators: Bool
/// The initial offset of the view as measured in the global frame
#State private var initialOffset: CGPoint?
/// The offset of the scroll view updated as the scroll view scrolls
#Binding public var scrollViewFrame: CGRect
#Binding public var offset: CGPoint
#Binding public var contentSize: CGSize
public init(_ axes: Axis.Set = .vertical,
showsIndicators: Bool = true,
offset: Binding<CGPoint> = .constant(.zero),
contentSize: Binding<CGSize> = .constant(.zero) ,
scrollViewFrame: Binding<CGRect> = .constant(.zero),
#ViewBuilder content: () -> Content) {
self.axes = axes
self.showsIndicators = showsIndicators
self._offset = offset
self._contentSize = contentSize
self.content = content()
self._scrollViewFrame = scrollViewFrame
}
public var body: some View {
ZStack {
GeometryReader { geometry in
Run {
let frame = geometry.frame(in: .global)
self.$scrollViewFrame.wrappedValue = frame
}
}
ScrollView(axes, showsIndicators: showsIndicators) {
ZStack(alignment: .leading) {
GeometryReader { geometry in
Run {
let frame = geometry.frame(in: .global)
let globalOrigin = frame.origin
self.initialOffset = self.initialOffset ?? globalOrigin
let initialOffset = (self.initialOffset ?? .zero)
let offset = CGPoint(x: globalOrigin.x - initialOffset.x, y: globalOrigin.y - initialOffset.y)
self.$offset.wrappedValue = offset
self.$contentSize.wrappedValue = frame.size
}
}
content
}
}
}
}
}
struct Run: View {
let block: () -> Void
var body: some View {
DispatchQueue.main.async(execute: block)
return AnyView(EmptyView())
}
}
extension CGPoint {
func reScale(from: CGRect, to: CGRect) -> CGPoint {
let x = (self.x - from.origin.x) / from.size.width * to.size.width + to.origin.x
let y = (self.y - from.origin.y) / from.size.height * to.size.height + to.origin.y
return .init(x: x, y: y)
}
func center(from: CGRect, to: CGRect) -> CGPoint {
let x = self.x + (to.size.width - from.size.width) / 2 - from.origin.x + to.origin.x
let y = self.y + (to.size.height - from.size.height) / 2 - from.origin.y + to.origin.y
return .init(x: x, y: y)
}
}
enum ArrowContentMode {
case center
case reScale
}
extension ArrowContentMode {
func transform(point: CGPoint, from: CGRect, to: CGRect) -> CGPoint {
switch self {
case .center:
return point.center(from: from, to: to)
case .reScale:
return point.reScale(from: from, to: to)
}
}
}
struct ArrowShape : Shape {
let contentMode : ArrowContentMode = .center
func path(in rect: CGRect) -> Path {
var path = Path()
let points = [
CGPoint(x: 0, y: 8),
CGPoint(x: 0, y: -8),
CGPoint(x: 0, y: 8),
CGPoint(x: 5.66, y: 2.34),
CGPoint(x: 0, y: 8),
CGPoint(x: -5.66, y: 2.34)
]
let minX = points.min { $0.x < $1.x }?.x ?? 0
let minY = points.min { $0.y < $1.y }?.y ?? 0
let maxX = points.max { $0.x < $1.x }?.x ?? 0
let maxY = points.max { $0.y < $1.y }?.y ?? 0
let fromRect = CGRect.init(x: minX, y: minY, width: maxX-minX, height: maxY-minY)
print("fromRect nx: ",minX,minY,maxX,maxY)
print("fromRect: \(fromRect), toRect: \(rect)")
let transformed = points.map { contentMode.transform(point: $0, from: fromRect, to: rect) }
print("fromRect: transformed=>\(transformed)")
path.move(to: transformed[0])
path.addLine(to: transformed[1])
path.move(to: transformed[2])
path.addLine(to: transformed[3])
path.move(to: transformed[4])
path.addLine(to: transformed[5])
return path
}
}
what I need is a way to tell when the user releases the scrollview, and if the pull to refresh arrow passed the threshold and was rotated, the scroll will move to a certain offset (say 32), and hide the arrow and show an ActivityIndicator.
NOTE: I tried using DragGesture but:
* it wont work on the scroll view
* OR block the scrolling on the scrollview content
You can use Introspect to get the UIScrollView, then from that get the publisher for UIScrollView.contentOffset and UIScrollView.isDragging to get updates on those values which you can use to manipulate your SwiftUI views.
struct Example: View {
#State var isDraggingPublisher = Just(false).eraseToAnyPublisher()
#State var offsetPublisher = Just(.zero).eraseToAnyPublisher()
var body: some View {
...
.introspectScrollView {
self.isDraggingPublisher = $0.publisher(for: \.isDragging).eraseToAnyPublisher()
self.offsetPublisher = $0.publisher(for: \.contentOffset).eraseToAnyPublisher()
}
.onReceive(isDraggingPublisher) {
// do something with isDragging change
}
.onReceive(offsetPublisher) {
// do something with offset change
}
...
}
If you want to look at an example; I use this method to get the offset publisher in my package ScrollViewProxy.
(For SwiftUI, not vanilla UIKit)
Very simple example code to, say, display red boxes on a gray background:
struct ContentView : View {
#State var points:[CGPoint] = [CGPoint(x:0,y:0), CGPoint(x:50,y:50)]
var body: some View {
return ZStack {
Color.gray
.tapAction {
// TODO: add an entry to self.points of the location of the tap
}
ForEach(self.points.identified(by: \.debugDescription)) {
point in
Color.red
.frame(width:50, height:50, alignment: .center)
.offset(CGSize(width: point.x, height: point.y))
}
}
}
}
I'm assuming instead of tapAction, I need to have a TapGesture or something? But even there I don't see any way to get information on the location of the tap. How would I go about this?
Well, after some tinkering around and thanks to this answer to a different question of mine, I've figured out a way to do it using a UIViewRepresentable (but by all means, let me know if there's an easier way!) This code works for me!
struct ContentView : View {
#State var points:[CGPoint] = [CGPoint(x:0,y:0), CGPoint(x:50,y:50)]
var body: some View {
return ZStack(alignment: .topLeading) {
Background {
// tappedCallback
location in
self.points.append(location)
}
.background(Color.white)
ForEach(self.points.identified(by: \.debugDescription)) {
point in
Color.red
.frame(width:50, height:50, alignment: .center)
.offset(CGSize(width: point.x, height: point.y))
}
}
}
}
struct Background:UIViewRepresentable {
var tappedCallback: ((CGPoint) -> Void)
func makeUIView(context: UIViewRepresentableContext<Background>) -> UIView {
let v = UIView(frame: .zero)
let gesture = UITapGestureRecognizer(target: context.coordinator,
action: #selector(Coordinator.tapped))
v.addGestureRecognizer(gesture)
return v
}
class Coordinator: NSObject {
var tappedCallback: ((CGPoint) -> Void)
init(tappedCallback: #escaping ((CGPoint) -> Void)) {
self.tappedCallback = tappedCallback
}
#objc func tapped(gesture:UITapGestureRecognizer) {
let point = gesture.location(in: gesture.view)
self.tappedCallback(point)
}
}
func makeCoordinator() -> Background.Coordinator {
return Coordinator(tappedCallback:self.tappedCallback)
}
func updateUIView(_ uiView: UIView,
context: UIViewRepresentableContext<Background>) {
}
}
I was able to do this with a DragGesture(minimumDistance: 0). Then use the startLocation from the Value on onEnded to find the tap's first location.
Update iOS 16
Starting form iOS 16 / macOS 13, the onTapGesture modifier makes available the location of the tap/click in the action closure:
struct ContentView: View {
var body: some View {
Rectangle()
.frame(width: 200, height: 200)
.onTapGesture { location in
print("Tapped at \(location)")
}
}
}
Original Answser
The most correct and SwiftUI-compatible implementation I come up with is this one. You can use it like any regular SwiftUI gesture and even combine it with other gestures, manage gesture priority, etc...
import SwiftUI
struct ClickGesture: Gesture {
let count: Int
let coordinateSpace: CoordinateSpace
typealias Value = SimultaneousGesture<TapGesture, DragGesture>.Value
init(count: Int = 1, coordinateSpace: CoordinateSpace = .local) {
precondition(count > 0, "Count must be greater than or equal to 1.")
self.count = count
self.coordinateSpace = coordinateSpace
}
var body: SimultaneousGesture<TapGesture, DragGesture> {
SimultaneousGesture(
TapGesture(count: count),
DragGesture(minimumDistance: 0, coordinateSpace: coordinateSpace)
)
}
func onEnded(perform action: #escaping (CGPoint) -> Void) -> _EndedGesture<ClickGesture> {
self.onEnded { (value: Value) -> Void in
guard value.first != nil else { return }
guard let location = value.second?.startLocation else { return }
guard let endLocation = value.second?.location else { return }
guard ((location.x-1)...(location.x+1)).contains(endLocation.x),
((location.y-1)...(location.y+1)).contains(endLocation.y) else {
return
}
action(location)
}
}
}
The above code defines a struct conforming to SwiftUI Gesture protocol. This gesture is a combinaison of a TapGesture and a DragGesture. This is required to ensure that the gesture was a tap and to retrieve the tap location at the same time.
The onEnded method checks that both gestures occurred and returns the location as a CGPoint through the escaping closure passed as parameter. The two last guard statements are here to handle multiple tap gestures, as the user can tap slightly different locations, those lines introduce a tolerance of 1 point, this can be changed if ones want more flexibility.
extension View {
func onClickGesture(
count: Int,
coordinateSpace: CoordinateSpace = .local,
perform action: #escaping (CGPoint) -> Void
) -> some View {
gesture(ClickGesture(count: count, coordinateSpace: coordinateSpace)
.onEnded(perform: action)
)
}
func onClickGesture(
count: Int,
perform action: #escaping (CGPoint) -> Void
) -> some View {
onClickGesture(count: count, coordinateSpace: .local, perform: action)
}
func onClickGesture(
perform action: #escaping (CGPoint) -> Void
) -> some View {
onClickGesture(count: 1, coordinateSpace: .local, perform: action)
}
}
Finally View extensions are defined to offer the same API as onDragGesture and other native gestures.
Use it like any SwiftUI gesture:
struct ContentView : View {
#State var points:[CGPoint] = [CGPoint(x:0,y:0), CGPoint(x:50,y:50)]
var body: some View {
return ZStack {
Color.gray
.onClickGesture { point in
points.append(point)
}
ForEach(self.points.identified(by: \.debugDescription)) {
point in
Color.red
.frame(width:50, height:50, alignment: .center)
.offset(CGSize(width: point.x, height: point.y))
}
}
}
}
An easy solution is to use the DragGesture and set minimumDistance parameter to 0 so that it resembles the tap gesture:
Color.gray
.gesture(DragGesture(minimumDistance: 0).onEnded({ (value) in
print(value.location) // Location of the tap, as a CGPoint.
}))
In case of a tap gesture it will return the location of this tap. However, it will also return the end location for a drag gesture – what's also referred to as a "touch up event". Might not be the desired behavior, so keep it in mind.
It is also possible to use gestures.
There is a few more work to cancel the tap if a drag occurred or trigger action on tap down or tap up..
struct ContentView: View {
#State var tapLocation: CGPoint?
#State var dragLocation: CGPoint?
var locString : String {
guard let loc = tapLocation else { return "Tap" }
return "\(Int(loc.x)), \(Int(loc.y))"
}
var body: some View {
let tap = TapGesture().onEnded { tapLocation = dragLocation }
let drag = DragGesture(minimumDistance: 0).onChanged { value in
dragLocation = value.location
}.sequenced(before: tap)
Text(locString)
.frame(width: 200, height: 200)
.background(Color.gray)
.gesture(drag)
}
}
Just in case someone needs it, I have converted the above answer into a view modifier which also takes a CoordinateSpace as an optional parameter
import SwiftUI
import UIKit
public extension View {
func onTapWithLocation(coordinateSpace: CoordinateSpace = .local, _ tapHandler: #escaping (CGPoint) -> Void) -> some View {
modifier(TapLocationViewModifier(tapHandler: tapHandler, coordinateSpace: coordinateSpace))
}
}
fileprivate struct TapLocationViewModifier: ViewModifier {
let tapHandler: (CGPoint) -> Void
let coordinateSpace: CoordinateSpace
func body(content: Content) -> some View {
content.overlay(
TapLocationBackground(tapHandler: tapHandler, coordinateSpace: coordinateSpace)
)
}
}
fileprivate struct TapLocationBackground: UIViewRepresentable {
var tapHandler: (CGPoint) -> Void
let coordinateSpace: CoordinateSpace
func makeUIView(context: UIViewRepresentableContext<TapLocationBackground>) -> UIView {
let v = UIView(frame: .zero)
let gesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.tapped))
v.addGestureRecognizer(gesture)
return v
}
class Coordinator: NSObject {
var tapHandler: (CGPoint) -> Void
let coordinateSpace: CoordinateSpace
init(handler: #escaping ((CGPoint) -> Void), coordinateSpace: CoordinateSpace) {
self.tapHandler = handler
self.coordinateSpace = coordinateSpace
}
#objc func tapped(gesture: UITapGestureRecognizer) {
let point = coordinateSpace == .local
? gesture.location(in: gesture.view)
: gesture.location(in: nil)
tapHandler(point)
}
}
func makeCoordinator() -> TapLocationBackground.Coordinator {
Coordinator(handler: tapHandler, coordinateSpace: coordinateSpace)
}
func updateUIView(_: UIView, context _: UIViewRepresentableContext<TapLocationBackground>) {
/* nothing */
}
}
Using some of the answers above, I made a ViewModifier that is maybe useful:
struct OnTap: ViewModifier {
let response: (CGPoint) -> Void
#State private var location: CGPoint = .zero
func body(content: Content) -> some View {
content
.onTapGesture {
response(location)
}
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onEnded { location = $0.location }
)
}
}
extension View {
func onTapGesture(_ handler: #escaping (CGPoint) -> Void) -> some View {
self.modifier(OnTap(response: handler))
}
}
Then use like so:
Rectangle()
.fill(.green)
.frame(width: 200, height: 200)
.onTapGesture { location in
print("tapped: \(location)")
}
Using DragGesture with minimumDistance broke scroll gestures on all the views that are stacked under. Using simultaneousGesture did not help. What ultimately did it for me was using sequencing the DragGesture to a TapGesture inside simultaneousGesture, like so:
.simultaneousGesture(TapGesture().onEnded {
// Do something
}.sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .global).onEnded { value in
print(value.startLocation)
}))
In iOS 16 and MacOS 13 there are better solutions, but to stay compatible with older os versions, I use this rather simple gesture, which also has the advantage of distinguish between single- and double-click.
var combinedClickGesture: some Gesture {
SimultaneousGesture(ExclusiveGesture(TapGesture(count: 2),TapGesture(count: 1)), DragGesture(minimumDistance: 0) )
.onEnded { value in
if let v1 = value.first {
var count: Int
switch v1 {
case .first(): count = 2
case .second(): count = 1
}
if let v2 = value.second {
print("combinedClickGesture couunt = \(count) location = \(v2.location)")
}
}
}
}
As pointed out several times before it´s a problem when the view already is using DragGesture, but often it is fixed when using the modifier:
.simultaneousGesture(combinedClickGesture)
instead of
.gesture(combinedClickGesture)
Posting this for others who still have to support iOS 15.
It's also possible using GeometryReader and CoordinateSpace. The only downside is depending on your use case you might have to specify the size of the geometry reader.
VStack {
Spacer()
GeometryReader { proxy in
Button {
print("Global tap location: \(proxy.frame(in: .global).center)")
print("Custom coordinate space tap location: \(proxy.frame(in: .named("StackOverflow")))")
} label: {
Text("Tap me I know you want it")
}
.frame(width: 42, height: 42)
}
.frame(width: 42, height: 42)
Spacer()
}
.coordinateSpace(name: "StackOverflow")