SwiftUI Touch Enter and Exit Gesture
Reading time: 5 min
This recipe shows how to detect when touch enters or exits your view. It shows how to do this for for two scenarios:
- Detect enter/exit in a single view.
- Detect enter/exit on a group of views, all sharing a single gesture.
The end results look like this:
Detecting Enter and Exit on Individual Views
This recipe implements a ViewModifier
that triggers callbacks when the user's touch enters and/or exits the view. To accomplish this, it:
- Tracks touch movement with a
DragGesture
. - Checks if the touch is within view boundaries using a
GeometryReader
in the background.
The same trick with a GeometryReader
in the background can also be used to measure SwiftUI views.
Here's the code for the TouchEnterExit
modifier and its View
extension:
struct TouchEnterExit: ViewModifier {
@GestureState private var dragLocation: CGPoint = .zero
@State private var didEnter = false
let onEnter: (() -> Void)?
let onExit: (() -> Void)?
func body(content: Content) -> some View {
content
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.updating($dragLocation) { value, state, _ in
state = value.location
}
)
.background(GeometryReader { geo in
dragObserver(geo)
})
}
private func dragObserver(_ geo: GeometryProxy) -> some View {
if geo.frame(in: .global).contains(dragLocation) {
DispatchQueue.main.async {
didEnter = true
onEnter?()
}
} else if didEnter {
DispatchQueue.main.async {
didEnter = false
onExit?()
}
}
return Color.clear
}
}
extension View {
func touchEnterExit(onEnter: (() -> Void)? = nil,
onExit: (() -> Void)? = nil) -> some View {
self.modifier(TouchEnterExit(onEnter: onEnter, onExit: onExit))
}
}
Then, you can use it as simply as:
.touchEnterExit { }, onExit: { }
Here's some code that shows it in action, resulting in the following preview:
// A simple rect that tracks if the touch is currently inside it
// and adjusts its label and outline accordingly
struct AwareRect: View {
@State private var text = "Default"
@State private var isInside = false
let id: Int
let color: Color
var body: some View {
ZStack {
color
if isInside {
Rectangle()
.stroke(Color.black, lineWidth: 10)
}
Text(text)
}
.frame(width: 150, height: 150)
.touchEnterExit {
text = "Entered \(id)"
isInside = true
} onExit: {
text = "Exited \(id)"
isInside = false
}
}
}
struct TouchEnterTouchExitTest: View {
private let colors = [Color.red, .blue, .green, .orange]
var body: some View {
HStack {
ForEach(0..<4) { i in
AwareRect(id: i, color: colors[i])
}
}
}
}
Detecting Enter and Exit on Group of Views
While the single view example works nicely and is extremely simple to use, it has one drawback: you can't detect enters/exits on multiple views with a single gesture. In other words, if you keep your finger down and move from one view to the other, only the first one will detect enter and exit.
To solve this, we need to reorder things a bit and make the detector views share the same gesture state. This is best solved using the proxy pattern, which we also used in weighted VStack/HStack.
First, add the proxy class that:
- Keeps track of all the views in a group and their frames. This requires all views to provide an ID.
- Checks the current touch position agains the stored frames and trigger callbacks accordingly. Each callback will provide the ID of the view in which the event happened.
class TouchEnterExitProxy<ID: Hashable> {
let onEnter: ((ID) -> Void)?
let onExit: ((ID) -> Void)?
private var frames = [ID: CGRect]()
private var didEnter = [ID: Bool]()
init(onEnter: ((ID) -> Void)?,
onExit: ((ID) -> Void)?) {
self.onEnter = onEnter
self.onExit = onExit
}
func register(id: ID, frame: CGRect) {
frames[id] = frame
didEnter[id] = false
}
func check(dragPosition: CGPoint) {
for (id, frame) in frames {
if frame.contains(dragPosition) {
DispatchQueue.main.async { [self] in
didEnter[id] = true
onEnter?(id)
}
} else if didEnter[id] == true {
DispatchQueue.main.async { [self] in
didEnter[id] = false
onExit?(id)
}
}
}
}
}
Next, the TouchEnterExitReader
will group the detector view by providing the proxy instance to them. It will also add a shared DragGesture
that it will use to trigger proxy checks.
struct TouchEnterExitReader<ID, Content>: View where ID : Hashable, Content : View {
private let proxy: TouchEnterExitProxy<ID>
private let content: (TouchEnterExitProxy<ID>) -> Content
init(_ idSelf: ID.Type, // without this, the initializer can't infer ID type
onEnter: ((ID) -> Void)? = nil,
onExit: ((ID) -> Void)? = nil,
@ViewBuilder content: @escaping (TouchEnterExitProxy<ID>) -> Content) {
proxy = TouchEnterExitProxy(onEnter: onEnter, onExit: onExit)
self.content = content
}
var body: some View {
content(proxy)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
proxy.check(dragPosition: value.location)
}
)
}
}
Lastly, here's the new modifier for individual views that share the same enter/exit detector. Again, it uses GeometryReader
to measure itself and report its frame to the proxy.
struct GroupTouchEnterExit<ID>: ViewModifier where ID : Hashable {
let id: ID
let proxy: TouchEnterExitProxy<ID>
func body(content: Content) -> some View {
content
.background(GeometryReader { geo in
dragObserver(geo)
})
}
private func dragObserver(_ geo: GeometryProxy) -> some View {
proxy.register(id: id, frame: geo.frame(in: .global))
return Color.clear
}
}
extension View {
func touchEnterExit<ID: Hashable>(id: ID, proxy: TouchEnterExitProxy<ID>) -> some View {
self.modifier(GroupTouchEnterExit(id: id, proxy: proxy))
}
}
Here's some code that shows it in action, resulting in the following preview:
// A simple rect that tracks if the touch is currently inside it
// and adjusts its label and outline accordingly
struct GroupAwareRect: View {
let id: Int
let color: Color
let isSelected: Bool
let proxy: TouchEnterExitProxy<Int>
var body: some View {
ZStack {
color
if isSelected {
Rectangle()
.stroke(Color.black, lineWidth: 10)
}
}
.frame(width: 150, height: 150)
.touchEnterExit(id: id, proxy: proxy) // as simple as this
}
}
struct GroupTouchEnterTouchExitTest: View {
private let colors = [Color.red, .blue, .green, .orange]
@State private var text = "Default"
@State private var selection: Int? = nil
var body: some View {
VStack {
Text(text)
// the reader tracks the events and provides a proxy
// that internal views can share
TouchEnterExitReader(Int.self,
onEnter: { id in
text = "Entered \(id)"
selection = id
},
onExit: { id in
text = "Exited \(id)"
selection = nil
}) { proxy in
HStack {
ForEach(0..<4) { i in
GroupAwareRect(id: i,
color: colors[i],
isSelected: i == selection,
proxy: proxy)
}
}
}
}
}
}