Capturando toques en una subvista fuera del marco de su supervista usando hitTest: withEvent:

94

Mi problema: tengo una supervista EditViewque ocupa básicamente todo el marco de la aplicación, y una subvista MenuViewque ocupa solo el ~ 20% inferior, y luego MenuViewcontiene su propia subvista ButtonViewque en realidad reside fuera de MenuViewlos límites (algo como esto :) ButtonView.frame.origin.y = -100.

(nota: EditViewtiene otras subvistas que no forman parte de MenuViewla jerarquía de vistas, pero que pueden afectar la respuesta).

Probablemente ya conozca el problema: cuando ButtonViewestá dentro de los límites de MenuView(o, más específicamente, cuando mis toques están dentro MenuViewde los límites de), ButtonViewresponde a eventos de toque. Cuando mis toques están fuera de MenuViewlos límites (pero aún dentro ButtonViewde los límites), no se recibe ningún evento de toque ButtonView.

Ejemplo:

  • (E) es EditViewel padre de todas las vistas
  • (M) es MenuViewuna subvista de EditView
  • (B) es ButtonViewuna subvista de MenuView

Diagrama:

+------------------------------+
|E                             |
|                              |
|                              |
|                              |
|                              |
|+-----+                       |
||B    |                       |
|+-----+                       |
|+----------------------------+|
||M                           ||
||                            ||
|+----------------------------+|
+------------------------------+

Debido a que (B) está fuera del marco de (M), nunca se enviará un toque en la región (B) a (M); de hecho, (M) nunca analiza el toque en este caso, y el toque se envía a el siguiente objeto de la jerarquía.

Objetivo: Supongo que la anulación hitTest:withEvent:puede resolver este problema, pero no entiendo exactamente cómo. En mi caso, ¿debería hitTest:withEvent:anularse en EditView(mi supervista 'maestra')? ¿O debería MenuViewanularse en la supervisión directa del botón que no recibe toques? ¿O estoy pensando en esto incorrectamente?

Si esto requiere una explicación extensa, un buen recurso en línea sería útil, excepto los documentos UIView de Apple, que no me lo han dejado claro.

¡Gracias!

toblerpwn
fuente

Respuestas:

145

He modificado el código de la respuesta aceptada para que sea más genérico: maneja los casos en los que la vista recorta subvistas a sus límites, puede estar oculta y, lo que es más importante: si las subvistas son jerarquías de vista complejas, se devolverá la subvista correcta.

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {

    if (self.clipsToBounds) {
        return nil;
    }

    if (self.hidden) {
        return nil;
    }

    if (self.alpha == 0) {
        return nil;
    }

    for (UIView *subview in self.subviews.reverseObjectEnumerator) {
        CGPoint subPoint = [subview convertPoint:point fromView:self];
        UIView *result = [subview hitTest:subPoint withEvent:event];

        if (result) {
            return result;
        }
    }

    return nil;
}

SWIFT 3

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {

    if clipsToBounds || isHidden || alpha == 0 {
        return nil
    }

    for subview in subviews.reversed() {
        let subPoint = subview.convert(point, from: self)
        if let result = subview.hitTest(subPoint, with: event) {
            return result
        }
    }

    return nil
}

Espero que esto ayude a cualquiera que intente utilizar esta solución para casos de uso más complejos.

Noam
fuente
Esto se ve bien y gracias por hacerlo. Sin embargo, tenía una pregunta: ¿por qué estás haciendo return [super hitTest: point withEvent: event]; ? ¿No devolvería nil si no hay una subvista que active el toque? Apple dice que hitTest devuelve nil si ninguna subvista contiene el toque.
Ser Pounce
Hm ... Sí, eso suena bastante bien. Además, para un comportamiento correcto, los objetos deben iterarse en orden inverso (porque el último es el más alto visualmente). Código editado para que coincida.
Noam
3
Acabo de usar su solución para hacer que un UIButton capture el toque, y está dentro de una UIView que está dentro de una UICollectionViewCell dentro de (obviamente) una UICollectionView. Tuve que subclasificar UICollectionView y UICollectionViewCell para anular hitTest: withEvent: en estas tres clases. ¡Y funciona de maravilla! Gracias !!
Daniel García
3
Dependiendo del uso deseado, debería devolver [super hitTest: point withEvent: event] o nil. Regresar al yo haría que lo recibiera todo.
Noam
1
Aquí hay un documento técnico de preguntas y respuestas de Apple sobre esta misma técnica: developer.apple.com/library/ios/qa/qa2013/qa1812.html
James Kuang
33

Ok, investigué un poco y probé, así es como hitTest:withEventfunciona, al menos a un alto nivel. Imagen de este escenario:

  • (E) es EditView , el padre de todas las vistas
  • (M) es MenuView , una subvista de EditView
  • (B) es ButtonView , una subvista de MenuView

Diagrama:

+------------------------------+
|E                             |
|                              |
|                              |
|                              |
|                              |
|+-----+                       |
||B    |                       |
|+-----+                       |
|+----------------------------+|
||M                           ||
||                            ||
|+----------------------------+|
+------------------------------+

Debido a que (B) está fuera del marco de (M), nunca se enviará un toque en la región (B) a (M); de hecho, (M) nunca analiza el toque en este caso, y el toque se envía a el siguiente objeto de la jerarquía.

Sin embargo, si implementa hitTest:withEvent:en (M), los toques en cualquier parte de la aplicación se enviarán a (M) (o al menos los conoce). Puede escribir código para manejar el toque en ese caso y devolver el objeto que debería recibir el toque.

Más específicamente: el objetivo de hitTest:withEvent: es devolver el objeto que debería recibir el impacto. Entonces, en (M) puede escribir código como este:

// need this to capture button taps since they are outside of self.frame
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{   
    for (UIView *subview in self.subviews) {
        if (CGRectContainsPoint(subview.frame, point)) {
            return subview;
        }
    }

    // use this to pass the 'touch' onward in case no subviews trigger the touch
    return [super hitTest:point withEvent:event];
}

Todavía soy muy nuevo en este método y este problema, por lo que si hay formas más eficientes o correctas de escribir el código, comente.

Espero que eso ayude a cualquiera que responda a esta pregunta más adelante. :)

toblerpwn
fuente
Gracias, esto funcionó para mí, aunque tuve que hacer algo más de lógica para determinar cuál de las subvistas debería recibir visitas. Por cierto, eliminé una pausa innecesaria de tu ejemplo.
Daniel Saidi
Cuando el marco de la subvista estaba más allá del marco de la vista principal, ¡el evento de clic no respondía! Su solución solucionó este problema. Muchas gracias :-)
byJeevan
@toblerpwn (apodo divertido :)) debes editar esta excelente respuesta anterior para que quede muy claro (UTILIZA MAYÚSCULAS GRANDES EN NEGRITA) en qué clase debes agregar esto. ¡Salud!
Fattie
26

En Swift 5

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    guard !clipsToBounds && !isHidden && alpha > 0 else { return nil }
    for member in subviews.reversed() {
        let subPoint = member.convert(point, from: self)
        guard let result = member.hitTest(subPoint, with: event) else { continue }
        return result
    }
    return nil
}
duan
fuente
Esto solo funcionará si aplica la anulación en la supervista directa de la vista de "comportamiento incorrecto"
Hudi Ilfeld
2

Lo que haría es que tanto ButtonView como MenuView existan en el mismo nivel en la jerarquía de vistas colocándolos en un contenedor cuyo marco se ajuste completamente a ambos. De esta forma, la región interactiva del elemento recortado no se ignorará debido a los límites de su supervista.

mamackenzie
fuente
También pensé en esta solución: significa que tendré que duplicar algo de lógica de ubicación (¡o refactorizar un código serio!), pero puede que sea mi mejor opción al final ..
toblerpwn
1

Si tiene muchas otras subvistas dentro de su vista principal, probablemente la mayoría de las otras vistas interactivas no funcionarían si usa las soluciones anteriores, en ese caso, puede usar algo como esto (en Swift 3.2):

class BoundingSubviewsViewExtension: UIView {

    @IBOutlet var targetView: UIView!

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        // Convert the point to the target view's coordinate system.
        // The target view isn't necessarily the immediate subview
        let pointForTargetView: CGPoint? = targetView?.convert(point, from: self)
        if (targetView?.bounds.contains(pointForTargetView!))! {
            // The target view may have its view hierarchy,
            // so call its hitTest method to return the right hit-test view
            return targetView?.hitTest(pointForTargetView ?? CGPoint.zero, with: event)
        }
        return super.hitTest(point, with: event)
    }
}
paras gupta
fuente
0

Si alguien lo necesita, aquí está la alternativa rápida.

override func hitTest(point: CGPoint, withEvent event: UIEvent?) -> UIView? {
    if !self.clipsToBounds && !self.hidden && self.alpha > 0 {
        for subview in self.subviews.reverse() {
            let subPoint = subview.convertPoint(point, fromView:self);

            if let result = subview.hitTest(subPoint, withEvent:event) {
                return result;
            }
        }
    }

    return nil
}
Hijo
fuente
0

Coloque las siguientes líneas de código en su jerarquía de vistas:

- (UIView*)hitTest:(CGPoint)point withEvent:(UIEvent*)event
{
    UIView* hitView = [super hitTest:point withEvent:event];
    if (hitView != nil)
    {
        [self.superview bringSubviewToFront:self];
    }
    return hitView;
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent*)event
{
    CGRect rect = self.bounds;
    BOOL isInside = CGRectContainsPoint(rect, point);
    if(!isInside)
    {
        for (UIView *view in self.subviews)
        {
            isInside = CGRectContainsPoint(view.frame, point);
            if(isInside)
                break;
        }
    }
    return isInside;
}

Para una mayor aclaración, se explicó en mi blog: "goaheadwithiphonetech" con respecto a "Llamada personalizada: problema en el que no se puede hacer clic en el botón".

Espero que eso te ayude...!!!

Sandip Patel - SM
fuente
Su blog ha sido eliminado, entonces, ¿dónde podemos encontrar la explicación?
ishahak