Triển Khai Hiệu Ứng Hoạt Ảnh Cho Control Pull-to-Refresh Trên iOS

Việc xây dựng cơ chế pull-to-refresh không chỉ dừng lại ở việc xử lý sự kiện cuộn mà còn đòi hỏi các hiệu ứng thị giác mượt mà để phản hồi trạng thái cho người dùng. Bài viết này đi sâu vào cách triển khai hai loại hoạt ảnh chính: thanh trạng thái dâng lên và vòng tròn tiến độ quay, dựa trên dữ liệu thời gian thực từ UIScrollView.

1. Cấu trúc khởi tạo Control chính

Thành phần quản lý chính, tạm gọi là RefreshContainer, sẽ được gắn vào scroll view. Trong quá trình khởi tạo, hai thành phần hiển thị hoạt ảnh sẽ được thêm vào view hierarchy:


- (instancetype)initWithScrollView:(UIScrollView *)scrollView {
    self = [super initWithFrame:CGRectZero];
    if (self) {
        // Cấu hình nhãn hướng dẫn
        ...
        
        // Khởi tạo hiệu ứng thanh dâng
        self.barIndicator = [self createBarIndicator];
        
        // Khởi tạo hiệu ứng vòng tròn quay
        self.spinnerView = [[ArcSpinnerView alloc] initWithFrame:CGRectMake(self.labelTitle.frame.origin.x - 35, self.bounds.size.height * 0.5 - 15, 30, 30)];
        [self addSubview:self.spinnerView];
        
        // Thiết lập trạng thái ban đầu
        ...
    }
    return self;
}

2. Hiệu ứng thanh trạng thái dâng lên (Bar Indicator)

Hiệu ứng này mô phỏng một thanh màu xanh dần xuất hiện từ dưới lên. Kỹ thuật sử dụng là chồng hai lớp view: một lớp nền màu xanh và một lớp mặt nạ màu trắng ở phía trước. Khi kéo, chiều cao của lớp mặt nạ sẽ giảm dần, lộ ra lớp nền bên dưới.

Khởi tạo view chỉ thị dạng thanh:


- (BarIndicatorView *)createBarIndicator {
    if (!_barIndicator) {
        _barIndicator = [[BarIndicatorView alloc] initWithFrame:CGRectMake(self.labelTitle.frame.origin.x - 30, self.bounds.size.height * 0.5 - 10, 20, 20)];
        _barIndicator.hiddenWhenIdle = NO;
        [self addSubview:_barIndicator];
    }
    return _barIndicator;
}

Chi tiết lớp BarIndicatorView sẽ quản lý hai view con là maskLayer (trắng) và contentLayer (xanh):


- (void)setupSubviews {
    // Lớp mặt nạ che phía trước
    self.maskLayer = [[UIView alloc] initWithFrame:self.bounds];
    UIImageView *maskImage = [[UIImageView alloc] initWithFrame:self.maskLayer.bounds];
    maskImage.image = [UIImage imageNamed:@"IMG_WHITE_MASK"];
    [self.maskLayer addSubview:maskImage];
    self.maskLayer.backgroundColor = [UIColor clearColor];
    self.maskLayer.clipsToBounds = YES;
   
    // Lớp nội dung phía sau
    self.contentLayer = [[UIView alloc] initWithFrame:self.bounds];
    UIImageView *contentImage = [[UIImageView alloc] initWithFrame:self.contentLayer.bounds];
    contentImage.image = [UIImage imageNamed:@"IMG_BLUE_BG"];
    [self.contentLayer addSubview:contentImage];
    self.contentLayer.backgroundColor = [UIColor clearColor];
    self.contentLayer.clipsToBounds = YES;
   
    [self addSubview:self.contentLayer];
    [self addSubview:self.maskLayer];
}

Logic điều khiển hoạt ảnh dựa trên hai sự kiện chính: khi người dùng kéo (scrollViewDidScroll) và khi trạng thái thay đổi (setState).

Trong phương thức xử lý scroll, tỷ lệ kéo được tính toán để điều chỉnh chiều cao của lớp mặt nạ:


- (void)scrollViewDidScroll:(CGPoint)offset {
    // Cập nhật trạng thái dựa trên offset
    ...
   
    if (self.currentState == RefreshStateHidden || self.currentState == RefreshStateVisible) {
        float distance = fabs(self.scrollView.contentOffset.y);
        if (distance > kMaxHeight) distance = kMaxHeight;
        
        // Điều chỉnh độ dài cung tròn
        ...
        
        // Cập nhật chiều cao thanh trạng thái
        [self.barIndicator updateHeightWithRatio:(distance / kMaxHeight)];
    } else if (self.currentState == RefreshStateTriggered) {
        ...
    }
}

Phương thức cập nhật chiều cao trong BarIndicatorView:


- (void)updateHeightWithRatio:(CGFloat)ratio {
    if (ratio > 1.0) ratio = 1.0;
    if (ratio < 0.0) ratio = 0.0;
   
    CGRect currentFrame = self.maskLayer.frame;
    CGFloat newHeight = self.frame.size.height * (1.0 - ratio);
    self.maskLayer.frame = CGRectMake(currentFrame.origin.x, currentFrame.origin.y, currentFrame.size.width, newHeight);
}

Khi trạng thái thay đổi, các hoạt ảnh sẽ được kích hoạt hoặc dừng lại tương ứng:


- (void)setState:(RefreshState)newState {
    _currentState = newState;
    switch (newState) {
        case RefreshStateHidden:
            ...
            [self.barIndicator haltAnimation];
            self.barIndicator.isComplete = NO;
            ...
            break;
           
        case RefreshStateVisible:
            ...
            [self.barIndicator haltAnimation];
            self.barIndicator.isComplete = NO;
            break;
           
        case RefreshStateTriggered:
            ...
            self.barIndicator.isComplete = YES;
            break;
           
        case RefreshStateLoading:
            ...
            [self.barIndicator beginAnimation];
            ...
            break;
    }
}

Ba phương thức cốt lõi trong BarIndicatorView để xử lý hoạt ảnh thanh:


- (void)beginAnimation {
    __weak BarIndicatorView *weakSelf = self;
    self.hidden = NO;
    self.isComplete = NO;
    self.isPaused = NO;
    CGRect targetRect = self.bounds;
    targetRect.size.height = 0;
   
    [UIView animateWithDuration:ANIMATION_DURATION delay:ANIMATION_DELAY options:UIViewAnimationOptionCurveLinear animations:^{
        // Giảm chiều cao mặt nạ để lộ nền xanh
        weakSelf.maskLayer.frame = targetRect;
    } completion:^(BOOL finished) {
        if (weakSelf.isPaused) return;
        // Lặp lại hoạt ảnh
        [weakSelf beginAnimation];
    }];
}
 
- (void)setIsComplete:(BOOL)complete {
    _isComplete = complete;
    if (!complete) {
        self.maskLayer.frame = self.bounds;
    } else {
        CGRect rect = self.bounds;
        rect.size.height = 0;
        self.maskLayer.frame = rect;
    }
}
 
- (void)haltAnimation {
    self.isPaused = YES;
    self.isComplete = YES;
    if (self.hiddenWhenIdle) {
        self.hidden = YES;
    }
}

3. Hiệu ứng vòng tròn tiến độ (Arc Spinner)

Thành phần thứ hai là vòng tròn quay, được quản lý bởi lớp ArcSpinnerView. Quá trình khởi tạo đơn giản hơn, chủ yếu thiết lập nền trong suốt và tỷ lệ tiến độ ban đầu:


- (instancetype)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    if (self) {
        self.backgroundColor = [UIColor clearColor];
    }
    return self;
}

Tương tự như thanh trạng thái, vòng tròn cũng phản hồi theo sự kiện scroll. Khi chưa đạt ngưỡng kích hoạt, cung tròn sẽ dài dần ra. Khi vượt quá ngưỡng, nó sẽ xoay tròn theo tốc độ kéo.


- (void)scrollViewDidScroll:(CGPoint)offset {
    ...
    if (self.currentState == RefreshStateHidden || self.currentState == RefreshStateVisible) {
        float distance = fabs(self.scrollView.contentOffset.y);
        if (distance > kMaxHeight) distance = kMaxHeight;
        
        // Vẽ cung tròn dựa trên tỷ lệ kéo
        [self.spinnerView renderArcWithRatio:(distance - kMaxHeight * 0.5) / (kMaxHeight * 0.5)];
        ...
    } else if (self.currentState == RefreshStateTriggered) {
        [self.spinnerView renderArcWithRatio:1.0];
        // Xoay vòng tròn khi kéo quá ngưỡng
        float distance = fabs(self.scrollView.contentOffset.y);
        self.spinnerView.transform = CGAffineTransformMakeRotation((distance - kMaxHeight) / 60.0 * 360.0 * M_PI / 180.0);
    }
}

Phương thức vẽ cung tròn sử dụng Core Graphics trong ArcSpinnerView:


- (void)renderArcWithRatio:(CGFloat)ratio {
    _currentRatio = ratio;
    [self setNeedsDisplay];
}
 
- (void)drawRect:(CGRect)rect {
    if (_currentRatio > 1.0) _currentRatio = 1.0;
    if (_currentRatio < 0.0) _currentRatio = 0.0;
    
    CGContextRef context = UIGraphicsGetCurrentContext();
    CGContextSetLineWidth(context, 1.0);
    CGContextSetLineCap(context, kCGLineCapRound);
    CGContextSetStrokeColorWithColor(context, [UIColor blueColor].CGColor);
    
    CGFloat startAngle = 0;
    CGFloat arcSpan = 11.4 * M_PI / 6.0 * _currentRatio;
    
    CGContextAddArc(context, self.bounds.size.width * 0.5, self.bounds.size.height * 0.5, self.bounds.size.width * 0.5 - 1, startAngle, startAngle + arcSpan, 0);
    CGContextStrokePath(context);
}

Khi chuyển sang trạng thái loading, vòng tròn sẽ sử dụng CABasicAnimation để xoay liên tục:


- (void)setState:(RefreshState)newState {
    _currentState = newState;
    switch (newState) {
        case RefreshStateHidden:
            [self.spinnerView haltAnimation];
            ...
            break;
        case RefreshStateLoading:
            ...
            self.spinnerView.transform = CGAffineTransformMakeRotation(0);
            [self.spinnerView beginAnimation];
            ...
            break;
        ...
    }
}

Triển khai chi tiết cho việc bắt đầu và dừng hoạt ảnh xoay:


- (void)beginAnimation {
    [self renderArcWithRatio:1.0];
   
    CABasicAnimation *spin = [CABasicAnimation animationWithKeyPath:@"transform.rotation.z"];
    spin.removedOnCompletion = FALSE;
    spin.fillMode = kCAFillModeForwards;
    [spin setToValue:[NSNumber numberWithFloat:M_PI / 2]];
    spin.repeatCount = HUGE_VALF;
    spin.duration = 0.25;
    spin.cumulative = TRUE;
    spin.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
   
    [self.layer addAnimation:spin forKey:@"spinAction"];
}
 
- (void)haltAnimation {
    [self.layer removeAnimationForKey:@"spinAction"];
}

Thẻ: iOS objective-c uiscrollview core-graphics cabasicanimation

Đăng vào ngày 7 tháng 6 lúc 23:16