Tìm hiểu về cách thức hoạt động của UIScrollView

UIScrollView là một trong những thành phần được sử dụng hầu hết trong tất cả các ứng dụng iOS. Có bao giờ bạn băn khoăn thực sự UIScrollView làm việc như thế nào không? Hôm nay hãy cùng tôi xem UIScrollView hoạt động như thế nào nhé. Kết thúc chúng ta sẽ có một ví dụ nho nhỏ để các bạn có thể nhìn thấy thành quả của mình.

Nhưng trước tiên, có lẽ chúng ta nên điểm lại một chút về cách làm việc của các hệ thống tọa độ (Coordinate Systems) của UIKit nhé.

Hệ thống tọa độ

Mỗi view có một hệ thống tọa độ riêng của chính nó. Nó trông giống như hình dưới đây với trục x là trục ngang (hướng sang phải) và trục y là trục dọc (hướng xuống dưới):

Hệ thống tọa độ

Chú ý rằng hệ thống tọa độ này không liên quan gì tới độ rộng (width) và chiều cao (height) của view. Nó không có giới hạn và có thể mở rộng vô hạn theo 4 hướng (thực tế là sẽ giới hạn bởi kiểu CGFloat - 32 bit với hệ thống 32 bit và 64 bit với hệ thống 64 bit). Bây giờ hãy thử đặt một vài subview vào hệ thống tọa độ này. Mỗi hình chữ nhật đại diện cho một subview (vậy là ta sẽ có 4 subviews):

Thêm 4 subviews vào hệ thống tọa độ

Việc này sẽ được thể hiện qua đoạn code dưới:

    UIView *redView = [[UIView alloc] initWithFrame:CGRectMake(20.f, 20.f, 100.f, 100.f)];
    redView.backgroundColor = [UIColor colorWithRed:0.815f green:0.007f blue:0.105f alpha:1.f];

    UIView *greenView = [[UIView alloc] initWithFrame:CGRectMake(150.f, 160.f, 150.f, 200.f)];
    greenView.backgroundColor = [UIColor colorWithRed:0.494f green:0.827f blue:0.129f alpha:1.f];

    UIView *blueView = [[UIView alloc] initWithFrame:CGRectMake(40.f, 400.f, 200.f, 150.f)];
    blueView.backgroundColor = [UIColor colorWithRed:0.29f green:0.564f blue:0.886f alpha:1.f];

    UIView *yellowView = [[UIView alloc] initWithFrame:CGRectMake(100.f, 600.f, 180.f, 150.f)];
    yellowView.backgroundColor = [UIColor colorWithRed:0.972f green:0.905f blue:0.109f alpha:1.f];

    [mainView addSubview:redView];
    [mainView addSubview:greenView];
    [mainView addSubview:blueView];
    [mainView addSubview:yellowView];

Bounds

Theo như tài liệu về UIView thì bounds property thể hiện vị trí và kích thước của view trong chính hệ thống tọa độ của nó.

The bounds rectangle, which describes the view’s location and size in its own coordinate system.

Một view có thể được xem như là một window hoặc viewport ở trong hình chữ nhật của mặt phẳng định nghĩa bởi hệ thống tọa độ của nó. Và bounds của view thể hiện vị trí và kích thước của hình chữ nhật này.

Ví dụ bounds của view có width là 320 và height là 480 points and và origin mặc định là (0, 0). Một view trở thành một viewport trong mặt phẳng hệ tọa độ, hiển thị một phần nhỏ của toàn bộ mặt phẳng đấy. Mọi thứ ở bên ngoài bounds sẽ vẫn ở đấy, chỉ là được ẩn đi. (Thực tế, trừ khi clipsToBounds == YES - mặc định là NO, nếu không các subview ở bên ngoài bounds vẫn có thể thấy được dù cho view không thể xác nhận được các touch bên ngoài bounds của nó:

Bounds của view thể hiện phần có thể hiển thị

Frame

Tiếp theo, chúng ta sẽ thay đổi origin của bounds:

 CGRect bounds = mainView.bounds;
 bounds.origin = CGPointMake(0.f, 100.f);
 mainView.bounds = bounds;

origin của bounds bây giờ sẽ là `(0.f, 100.f) như hình sau:

Viewport được di chuyển

Nó nhìn giống như là view đã được dịch xuống 100 points nhưng thực tế đây chỉ là mối liên kết với hệ thống tọa độ của chính nó. Vị trí thực sự của view ở trên màn hình hoặc trong view cha vẫn cố định. Tuy nhiên giá trị đó được xác định bởi frame - thứ mà không hề thay đổi:

The frame rectangle, which describes the view’s location and size in its superview’s coordinate system.

Bởi vì vị trí của view vẫn là cố định, hãy tưởng tượng hệ thống tọa độ như là một phần của một cuốn phim trong suốt mà chúng ta có thể kéo vòng quanh và view thì như một cửa sổ cố định mà chúng ta nhìn qua. Thay đổi origin của bounds giống như việc di chuyển cuộn phim khiến cho phần khác của nó có thể hiển thị qua view - cửa sổ ta đang nhìn

Thay đổi origin của bounds giống như di chuyển cuốn phim qua ô cửa sổ

Và đó chính là cơ chế hoạt động của UIScrollView khi nó scroll. Chú ý rằng trên góc nhìn của người dùng thì sẽ thấy rằng các subview của view đang di chuyển mặc dù vị trí của chúng trên hệ tọa độ của view (hay chính là frame của chúng) thì vẫn giữ nguyên.

Thử xây dựng Scroll View (dạng đơn giản)

Như chúng ta đã tìm hiểu phần trên, một scroll view không cần luôn cập nhật toạn độ của các subview để khiến nó scroll. Tất cả việc phải làm chỉ là thay đổi origin của bounds. Khi bạn đã hiểu điều đó, việc xây dựng một scroll view dạng đơn giản không còn quá phức tạp. Chúng ta sẽ thiết lập một gesture recognizer để xác nhận được pan gestures của user và chúng ta thay đổi bounds của view bằng khoảng được kéo:

 // CustomScrollView.h
 #import <UIKit/UIKit.h>

 @interface CustomScrollView : UIView

 @property (nonatomic) CGSize contentSize;

 @end
 // CustomScrollView.m
 #import "CustomScrollView.h"

@implementation CustomScrollView

- (id)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    if (self == nil) {
        return nil;
    }
    UIPanGestureRecognizer *gestureRecognizer = [[UIPanGestureRecognizer alloc]
        initWithTarget:self action:@selector(handlePanGesture:)];
    [self addGestureRecognizer:gestureRecognizer];
    return self;
}

- (void)handlePanGesture:(UIPanGestureRecognizer *)gestureRecognizer {
    CGPoint translation = [gestureRecognizer translationInView:self];
    CGRect bounds = self.bounds;

    // Translate the view's bounds, but do not permit values that would violate contentSize
    CGFloat newBoundsOriginX = bounds.origin.x - translation.x;
    CGFloat minBoundsOriginX = 0.0;
    CGFloat maxBoundsOriginX = self.contentSize.width - bounds.size.width;
    bounds.origin.x = fmax(minBoundsOriginX, fmin(newBoundsOriginX, maxBoundsOriginX));

    CGFloat newBoundsOriginY = bounds.origin.y - translation.y;
    CGFloat minBoundsOriginY = 0.0;
    CGFloat maxBoundsOriginY = self.contentSize.height - bounds.size.height;
    bounds.origin.y = fmax(minBoundsOriginY, fmin(newBoundsOriginY, maxBoundsOriginY));

    self.bounds = bounds;
    [gestureRecognizer setTranslation:CGPointZero inView:self];
}

@end

Cũng giống như UIScrollView thực sự, class của chúng ta cũng cần có property contentSize và sẽ được set giá trị ở bên goài (sau khi khởi tạo CustomScrollView) để định nghĩa khu vực có thể scroll. Khi chúng ta thay đổi bounds, chúng ta cần kiểm tra giá trị là hợp lệ.

Kết quả:

Custom ScrollView

Tổng kết

Như vậy là chưa tới 30 dòng code, chúng ta đã có thể làm được tính năng cơ bản của UIScrollView. Tất nhiên, một UIScrollView thực tế của hệ thống còn có rất nhiều các việc khác như bouncing, scroll indicator, zooming, delegate ... nhưng qua ví dụ nho nhỏ này, chúng ta đã hiểu hơn về UIScrollView cũng như về hệ thống tọa độ.

Các bạn có thể download source của ứng dụng đơn giản mà mình làm tại đây để tham khảo thêm.


All Rights Reserved