Hit test trong iOS

Hit test là quá trình hệ thống iOS xác định touch point thuộc view nào được chạm vào trong view hierarchy để xử lý các touch event. Việc tìm kiếm trong view hierarchy được thực hiện bằng thuật toán duyệt theo chiều sâu DFS trên cây nhị phân sắp xếp trước.

Trước khi tìm hiểu cách thức hoạt động của hit test, chúng ta cần biết được khi nào thì hit test sẽ xảy ra.

Quá trình hit test được thực hiện mỗi khi ngón tay người dùng chạm vào màn hình và trước lúc khi mà bất kỳ view hoặc gesture recognizer nào nhận được các event object UIEvent.

Sơ đồ dưới đây mô phỏng quá trình từ lúc người dùng chạm vào màn hình, di chuyển ngón tay, cho đến lúc nhấc ngón tay lên.

Sau khi hit test, và touch point của view nằm trên cùng trong view hierarchy được xác định thì view đó và các gesture recognizer thuộc nó hoặc super view sẽ được gán object UITouch cho toàn bộ quá trình còn lại của hành động touch (ví dụ: began, moved, ended hoặc canceled) và bắt đầu nhận và xử lý các touch event.

Một điều quan trọng cần chú ý đó là ngay cả khi ngón tay di chuyển ra ngoài bounds của hit test view, ra ngoài view khác thì hit test view vẫn tiếp tục nhận và xử lý các touch cho đến khi kết thúc touch event.

“The touch object is associated with its hit-test view for its lifetime, even if the touch later moves outside the view.”

Event Handling Guide for iOS, iOS Developer Library

Như đã đề cập trước đó, hit test sử dụng thuật toán duyệt theo chiều sâu trên cây nhị phân đã sắp xếp trước (thăm node gốc rồi thăm các node con của nó theo thứ tự index từ cao đến thấp).

Cách duyệt này cho phép giảm số vòng lặp khi duyệt và quá trình tìm kiếm có thể kết thúc ngay khi đã tìm thấy view con chứa touch point.

Một subview có thể được render nằm ngay trên superview của nó và các view "anh chị em" cùng cấp luôn được render nằm ngay trên nhau

Biểu đồ sau là một ví dụ về view hierarchy tree thể hiện các view được hiển thị trên màn hình. Thứ tự của các nhánh từ trái sang phải thể hiện thứ tự trong mảng subview.

Có thể thấy rằng, View AView B hiển thị đè lên nhau, các view con View A.2View B.1 cũng vậy. Nhưng vì View B có subview index lớn hơn của View A nên View B và các subview của nó được hiển thị lên trên View A và các subview của View A.

Chính vì thể mà hit test sẽ trả về View B.1 khi mà ngón tay người dùng chạm vào vùng đè lên nhau giữa View B.1View A.2.

Ưu điểm của việc duyệt theo chiều sâu DFS trên cây nhị phân đã sắp xếp là nó cho phép dừng phép tìm kiếm ngay khi view con đầu tiên sâu nhất chứa touch point được tìm thấy.

Thuật toán duyệt tìm bắt đầu bằng việc gọi method hitTest:withEvent: tới root view của view hierarchy là UIWindow. Giá trị trả về của method này sẽ là view nằm trên cùng chứa touch point.

Sơ đồ sau biểu diễn logic của hit test:

Và đoạn code sau mô phỏng logic của hit test, theo những gì Apple mô tả:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
        return nil;
    }
    if ([self pointInside:point withEvent:event]) {
        for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
            CGPoint convertedPoint = [subview convertPoint:point fromView:self];
            UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
            if (hitTestView) {
                return hitTestView;
            }
        }
        return self;
    }
    return nil;
}

Method hitTest:withEvent: đầu tiên sẽ kiểm tra xem view đó có thể nhận touch event hay không. Một view thỏa mãn điều kiện này là một view mà:

  • View đó không bị ẩn: self.hidden == NO
  • View cho phép user interact: self.userInteractionEnabled == YES
  • View có alpha lớn hơn 0.01: self.alpha > 0.01
  • View chứa touch point: pointInside:withEvent: == YES

Sau khi kiểm tra view đó có thể chứa touch point, method này tiếp tục duyệt vào các subview của nó và đệ quy method hitTest:withEvent: và dừng lại khi thu được một giá trị khác nil.

Nếu view đó không thỏa mãn một trong các điều kiện trên thì method sẽ trả về giá trị nil mà không cần duyệt qua các subview của nó. Vì thế, quá trình hit test không cần kiểm tra qua toàn bộ các view của view hierarchy.

Các trường hợp cần override hit test

Tăng kích thước vùng tap cho view

Một trường hợp thực tế thường dùng đến việc override method hitTest:withEvent: là khi chúng ta muốn khu vực có thể tap của một view cần rộng hơn bounds vốn có của nó.

Ví dụ, hình dưới đây thể hiện một UIView có kích thước 20x20. Kích thước này hơi nhỏ để người dùng có thể tap chính xác vào được. Vì vậy cần override hitTest:withEvent: để tăng kích thước vùng tap của nó thêm 10 point mỗi chiều, thành 40x40.

Code implement:

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    if (!self.isUserInteractionEnabled || self.isHidden || self.alpha <= 0.01) {
        return nil;
    }
    // Tăng 10 point cho mỗi chiều, size mới sẽ thành 40x40
    CGRect touchRect = CGRectInset(self.bounds, -10, -10); 
    if (CGRectContainsPoint(touchRect, point)) {
        for (UIView *subview in [self.subviews reverseObjectEnumerator]) {
            CGPoint convertedPoint = [subview convertPoint:point fromView:self];
            UIView *hitTestView = [subview hitTest:convertedPoint withEvent:event];
            if (hitTestView) {
                return hitTestView;
            }
        }
        return self;
    }
    return nil;
}

Truyền các touch event xuống các view nằm bên dưới

Trong một số trường hợp cần thiết, chúng ta cần ignore touch event và cần pass chúng xuống view nằm bên dưới một view.

Ví dụ, chúng ta có một overlay view trong suốt đè lên toàn bộ màn hình. Overlay view này có một số subview hiển thị một form có các field nhập text và các button. Khi nhấn vào các subview này thì vẫn nhận các touch event bình thường, nhưng tap vào các vùng trong suốt còn lại thì cần truyền các touch event xuống các view bên dưới.

Để làm được yêu cầu này, cần override hitTest:withEvent: cho overlay view đó và return nil ngay khi giá trị trả về là chính nó.

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    UIView *hitTestView = [super hitTest:point withEvent:event];
    if (hitTestView == self) {
        hitTestView = nil;
    }
    return hitTestView;
}

Truyền các touch event xuống các subview

Một trường hợp khác cần override hit test đó là cần redirect tất cả các event touch của một view cho các subview của nó. Ví dụ một slideshow làm từ UIScrollView và các UIImageView, scrollview set pagingEnable = YES và clipToBounds bằng NO. Lúc này các event touch của scrollview cần được truyền xuống các subview của nó để tạo được hiệu ứng chuyển động.

Để UIScrollView không những handle các touch event bên trong bounds mà còn bên ngoài bounds của nó nhưng vẫn bên trong parent view, thì parent view của UIScrollView cần override hitTest:withEvent::

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
    UIView *hitTestView = [super hitTest:point withEvent:event];
    if (hitTestView) {
        hitTestView = self.scrollView;
    }
    return hitTestView;
}

Source article


All Rights Reserved