Customize animation table view header giống app Tiki

Introduction

Thỉnh thoảng khi sử dụng các app nổi tiếng, chúng ta có thể thấy có một số màn hình có phần header được animate thu nhỏ, phóng to, thay đổi cấu trúc UI mỗi khi user scroll lên hoặc xuống.

Ví dụ như màn hình profile của app Twitter:

Hoặc trang chủ của app Tiki:

Hoặc UI ấn tượng của app Uber Eats:

Việc customize animate header như vậy sẽ làm cho UX của app mới mẻ và bớt nhàm chán, đơn điệu. Khi ở top, user có nhìn thấy được toàn bộ phần header, còn khi scroll xuống thì header co lại, app sẽ hiển thị được nhiều thông tin hơn.

Trong bài viết này, chúng ta sẽ cùng thử bắt chước làm animation cho header gần giống app Tiki trên. Header sẽ expand hoặc collapse tùy thuộc vào table view đang được scroll lên hay xuống.

Getting started

Để bắt đầu, chúng ta cần tạo một UIViewController trong đó chứa một UIView và một UITableView. Hãy đặt tên cho UIView vừa tạo là Header View và constraint nó với top, left, right của UIViewController, còn height constraint thì set bằng 88. Tương tự, UITableView thì set constraint bottom, left, right với UIViewController và top constraint bằng bottom constraint của Header View. Cụ thể như sau:

Để thay đổi height của header view, chúng ta cần tạo IBOutlet cho height constaint của Header View. Để implement data cho table view, cần tạo thêm IBOutlet cho cả table view nữa.

Việc dummy data cho table view được thực hiện đơn giản như sau:

import UIKit

class ViewController: UIViewController {

    // MARK: IBOutlets
    @IBOutlet private weak var tableView: UITableView!
    @IBOutlet private weak var headerHeightConstraint: NSLayoutConstraint!

    // MARK: View life cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.dataSource = self
        tableView.delegate = self
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "TableViewCell")
    }
}

// MARK: UITableViewDataSource methods
extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 50
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "TableViewCell", for: indexPath)
        cell.textLabel?.text = "This is cell \(indexPath.row)"
        return cell
    }
}

// MARK: UITableViewDataSource methods
extension ViewController: UITableViewDelegate {

}

Defining min and max values

Giống các app ví dụ kể trên, khi mới vào các màn hình này, ban đầu header sẽ được hiển thị với max height. Ở trạng thái này, header sẽ có đầy đủ các thành phần UI cần thiết. Khi scoll xuống dưới, các UI như title, image, button sẽ được tự động sắp xếp lại, co lại để tiết kiệm tối đa không gian, lấy chỗ để hiển thị được nhiều content bên dưới hơn.

Để làm được animation này, chúng ta cần khai báo các maximum value và minimum value thể hiện height của header khi được expand hay collapse tối đa. Hãy thêm các class properties sau:

let maxHeaderHeight: CGFloat = 88.0
let minHeaderHeight: CGFloat = 44.0

Ở trạng thái collapse tối đa, header sẽ có height bằng một nửa so với trạng thái expand tối đa ban đầu.

Để đảm bảo header luôn được expand tối đa khi view controller được hiển thị, rất đơn giản, trong methods viewWillAppear chỉ cần set lại headerHeightConstraint bằng maxHeaderHeight:

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    headerHeightConstraint.constant = maxHeaderHeight
}

Bây giờ thì đã implement được trạng thái khởi tạo expand tối đa của header. Tiếp theo đến phần animate header khi scroll.

Scrolling up and down

Protocol UIScrollViewDelegate (tự động conform trong protocol UITableViewDelegate) có rất nhiều method hữu dụng giúp chúng ta theo dõi, xử lý việc scroll của scroll view. Method đầu tiên mà chúng ta sẽ sử dụng ở đây đó là scrollViewDidScroll(scrollView: UIScrollView). Method này được gọi mỗi khi scroll position của table view (scroll view) bị thay đổi. Có thể sử dụng property contentOffset.y của scroll view để lấy được scroll position hiện tại. Từ đó resize, animate header view.

Để xác định xem scroll view đang scroll lên hay xuống, một cách đơn giản là sử dụng một class property nữa (ví dụ previousScrollOffset). Sau đó lấy scroll position hiện tại trừ đi scroll position trước đó, nếu nhỏ hơn 0 thì có nghĩa là scroll view đang scroll lên, còn nếu nhỏ hơn 0 thì đang scroll xuống.

Giả sử property previousScrollOffset được set đúng value thì logic trên được cụ thể hóa thành code như sau:

private var previousScrollOffset: CGFloat = 0.0

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let scrollDiff = scrollView.contentOffset.y - previousScrollOffset
    let isScrollingDown = scrollDiff > 0
    let isScrollingUp = scrollDiff < 0
}

Để set đúng value cho previousScrollOffset, chỉ cần set nó bằng với scroll position hiện tại ở cuối method:

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    let scrollDiff = scrollView.contentOffset.y - previousScrollOffset
    let isScrollingDown = scrollDiff > 0
    let isScrollingUp = scrollDiff < 0

    // Implement logic để animate header

    previousScrollOffset = scrollView.contentOffset.y
}

Sau khi xác định được scroll direction, chúng ta có thể tính toán và thay đổi height của header. Header sẽ collapse theo tỉ lệ mà scroll view offset thay đổi nên có thể sử dụng biến scrollDiff ở trên để tính toán height cần thay đổi. Tuy nhiên, header lại không được expand hay collapse ngoài quá max, min height đã khai báo ở trên. Vì vậy, chúng ta cần sử dụng các hàm max, min, abs để tính toán và giới hạn lại giá trị height:

var newHeight = headerHeightConstraint.constant
if isScrollingDown {
    newHeight = max(minHeaderHeight, headerHeightConstraint.constant - abs(scrollDiff))
} else if isScrollingUp {
    newHeight = min(maxHeaderHeight, headerHeightConstraint.constant + abs(scrollDiff))
}

if newHeight != headerHeightConstraint.constant {
    headerHeightConstraint.constant = newHeight
}

Trong đoạn code trên, biến newHeight được dùng để xác định new height cho header dựa trên scroll direction. Giá trị new height này sẽ được apply vào headerHeightConstraint nếu nó khác với constant hiện tại.

Nếu run thử project ngay lúc này, chúng ta có thể thấy có một số behavior dị thường xuất hiện mỗi khi scroll để top hoặc bottom của scroll view. Điều này là vì animation bounce mặc định của scroll view mỗi khi scroll view được kéo ngoài giới hạn content size của nó. Vì vậy đoạn logic xác định scroll direction lên hay xuống ở trên bị sai khi scroll view bị bounce như vậy.

Để fix bug này chỉ cần thêm một số logic check như sau:

// Điểm giới hạn trên cùng của scroll view
let absoluteTop: CGFloat = 0.0
// Điểm giới hạn dưới cùng của scroll view
let absoluteBottom: CGFloat = scrollView.contentSize.height - scrollView.frame.size.height

let isScrollingDown = scrollDiff > 0 && scrollView.contentOffset.y > absoluteTop
let isScrollingUp = scrollDiff < 0 && scrollView.contentOffset.y < absoluteBottom

Với việc update scroll direction như trên, run lại project, chúng ta sẽ thấy header expand và collapse mượt mà mỗi khi scroll đến top hoặc bottom của scroll view.

Trong một số trường hợp, khi mà table view chỉ có một vài cell và content size của scroll view nhỏ hơn height của screen, chúng ta sẽ không cần collapse header. Vì trong trường hợp này, tính cả header và content của table view, vẫn còn đủ không gian trong màn hình. Vì vậy, để ngăn header collapse trong trường hợp ngoại lệ này, chúng ta cần check xem có còn không gian để scroll không ngay cả khi header bị collapse lại. Thêm đoạn code check đơn giản sau trước đoạn logic tính toán new height để fix case này:

guard canAnimateHeader(scrollView) else {
    return
}

Method canAnimateHeader(_ scrollView: UIScrollView) -> Bool:

private func canAnimateHeader(_ scrollView: UIScrollView) -> Bool {
    // Calculate height của scroll view khi header view bị collapse đến min height
    let scrollViewMaxHeight = scrollView.frame.height + headerHeightConstraint.constant - minHeaderHeight
    // Đảm bảo khi header bị collapse đến min height thì scroll view vẫn scroll được
    return scrollView.contentSize.height > scrollViewMaxHeight
}

Stop scrolling while expanding/collapsing

Một điều cần làm nữa để đảm bảo việc animate header diễn ra mượt mà đó là chúng ta phải đóng băng việc scroll của scroll view trong lúc header đang expand/collapse. Vì trong đoạn code trên, chúng ta đã có câu lệnh if để check xem khi new height khác với giá trị constant của headerHeightConstraint hiện tại nên có thể dựa vào đó để biết khi nào thì header đang được animate.

if newHeight != self.headerHeightConstraint.constant {
    headerHeightConstraint.constant = newHeight
    setScrollPosition(previousScrollOffset)
}

Method setScrollPosition(_ position: CGFloat):

private func setScrollPosition(_ position: CGFloat) {
    tableView.contentOffset = CGPoint(x: tableView.contentOffset.x, y: position)
}

Run project và kiểm chứng, mỗi khi header đang được expand/collapse thì table view của chúng ta không còn bị scroll theo và bị header che mất nữa.

Snap header fully expanded/collapsed

Behavior này giúp header không bị lơ lửng ở giữa 2 state fully expanded hoặc fully collapsed. Việc implement animation trong scrollViewDidScroll là không đủ. Mà UIScrollViewDelegate cũng không có delegate method nào là scrollViewDidStop() nên chúng ta cần kết hợp 2 method có sẵn để xác định khi nào thì kết thúc scroll.

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    // Kết thúc scroll
}

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    if !decelerate {
        // Kết thúc scroll
    }
}

Như cái tên của method, scrollViewDidEndDecelerating() cho chúng ta biết khi nào thì scroll view dừng scroll sau quá trình "di chuyển" và "giảm tốc". Còn scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) thì cho biết khi nào scroll view dừng scroll sau khi user nhấc ngón tay lên. Biến boolean decelerate bằng true nếu scroll view tiếp tục di chuyển sau đó. Còn bằng false nghĩa là scroll chuẩn bị dừng lại.

Sau khi xác định được thời điểm scroll view dừng scroll, chúng ta cần tạo một helper method để implement logic để animate header expand hay collapse, không có trạng thái lơ lửng ở giữa. Header height sẽ chỉ có 2 state max hoặc min. Để làm được điều này cần tính được một điểm mid point. Nếu height của header lớn hơn điểm mid point thì sẽ expand hoàn toàn header. Và ngược lại sẽ collapse hoàn toàn header.

 func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
     scrollViewDidStopScrolling()
 }

 func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
     if !decelerate {
         scrollViewDidStopScrolling()
     }
 }

 private func scrollViewDidStopScrolling() {
     let range = maxHeaderHeight - minHeaderHeight
     let midPoint = minHeaderHeight + range / 2

     if headerHeightConstraint.constant > midPoint {
         // Expand header
         headerHeightConstraint.constant = maxHeaderHeight
     } else {
         // Collapse header
         headerHeightConstraint.constant = minHeaderHeight
     }
 }

Nếu run app ngay, header đã không còn trạng thái lơ lửng giữa min/max height khiừng scroll giữa chừng nữa. Tuy nhiên việc expand/collapse header khi header vượt quá mid point không có animation nên nhìn rất giật cục.

Thêm và thay thế animation với đoạn code sau:

 private func scrollViewDidStopScrolling() {
     let range = maxHeaderHeight - minHeaderHeight
     let midPoint = minHeaderHeight + range / 2

     if headerHeightConstraint.constant > midPoint {
         // Expand header
         expandHeader()
     } else {
         // Collapse header
         collapseHeader()
     }
 }

 private func collapseHeader() {
     view.layoutIfNeeded()
     UIView.animate(withDuration: 0.2, animations: {
         self.headerHeightConstraint.constant = self.minHeaderHeight
         self.view.layoutIfNeeded()
     })
 }

 func expandHeader() {
     view.layoutIfNeeded()
     UIView.animate(withDuration: 0.2, animations: {
         self.headerHeightConstraint.constant = self.maxHeaderHeight
         self.view.layoutIfNeeded()
     })
 }

Animating elements within the header

Trong phần này, chúng ta sẽ thực hiện animate, thay đổi vị trí, cấu trúc, cách sắp xếp các thành phần UI trong header view.

Trong storyboard, embed Header View vào một UIView mới, đặt tên là Header View Container. Set top constraint của Header View Container bằng top constraint của super view (không phải với safe area), left, right với safe area, bottom với top của UITableView. Set màu cho container view trùng màu với header view đã có. Mục đích là để cover và fill cùng màu lên status bar.

Constraint của header view thì update lại: top với top của view controller, left, right, bottom với left, right, bottom của container.

Tiếp theo thêm các UIImageView, UIButtonUITextField và constraint như sắp xếp sau:

Khi collapse header view, chúng ta sẽ làm mờ dần image view và thay đổi constant trailing constraint của text field. Vì vậy cần tạo IBOutlet cho chúng trong view controller. Các UI element còn lại như 2 button thì vị trí cố định ở top right, không thay đổi. Text field sẽ co lại những vẫn giữ nguyên ở vị trí bottom left.

Ngoài ra để tính toán được khoảng co lại của text field sao cho bằng với min x của button chat, chúng ta vẫn cần tạo IBOutlet cho chat button này.

 @IBOutlet private weak var logoImageView: UIImageView!
 @IBOutlet private weak var searchTextFieldTrailingConstraint: NSLayoutConstraint!
 @IBOutlet private weak var chatButton: UIButton!

Method updateHeader() để thực hiện animate UI element trong header như sau:

 private func updateHeader() {
     // Tính khoảng cách giữa 2 value max và min height
     let range = maxHeaderHeight - minHeaderHeight
     // Tính khoảng offset hiện tại với min height
     let openAmount = headerHeightConstraint.constant - minHeaderHeight
     // Tính tỉ lệ phần trăm để animate, thay đổi UI element
     let percentage = openAmount / range
     // Tính constant của trailing constraint cần thay đổi
     let trailingRange = view.frame.width - chatButton.frame.minX

     // Animate UI theo tỉ lệ tính được
     searchTextFieldTrailingConstraint.constant = trailingRange * (1.0 - percentage) + 8
     logoImageView.alpha = percentage
 }

Cuối cùng, gọi method updateHeader() này trong viewWillAppear(), collapseHeader(), expandHeader() và cả scrollViewDidScroll():

 private func collapseHeader() {
     view.layoutIfNeeded()
     UIView.animate(withDuration: 0.2, animations: {
         self.headerHeightConstraint.constant = self.minHeaderHeight
         self.updateHeader()
         self.view.layoutIfNeeded()
     })
 }

 private func expandHeader() {
     view.layoutIfNeeded()
     UIView.animate(withDuration: 0.2, animations: {
         self.headerHeightConstraint.constant = self.maxHeaderHeight
         self.updateHeader()
         self.view.layoutIfNeeded()
     })
 }
 
 func scrollViewDidScroll(_ scrollView: UIScrollView) {
     ...
     if newHeight != self.headerHeightConstraint.constant {
         headerHeightConstraint.constant = newHeight
         updateHeader()
         setScrollPosition(previousScrollOffset)
     }
     ...
 }    

Kết quả:


All Rights Reserved