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:

  1. Detect enter/exit in a single view.
  2. Detect enter/exit on a group of views, all sharing a single gesture.

The end results look like this:

preview_single preview_group

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:

  1. Tracks touch movement with a DragGesture.
  2. 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:

preview_single

// 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:

  1. Keeps track of all the views in a group and their frames. This requires all views to provide an ID.
  2. 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:

preview_group

// 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)
          }
        }
      }
    }
  }
}

Next Post Previous Post