Skip to main content

Tạo placeholder loading animation giống Facebook, Youtube (Phần 2)


Introduction

https://viblo.asia/p/tao-placeholder-loading-animation-giong-facebook-youtube-phan-1-RQqKLv8ml7z
Ở bài viết lần trước, chúng ta đã tìm hiểu cơ bản về CAGradientLayer, CABasicAnimation để tạo placeholder có loading animation giống app Facebook iOS, Youtube iOS.

Cụ thể là ta đã tạo được hiệu ứng lướt từ trái sang phải cho một single view.

Trong bài này, hãy cùng nhau hoàn thiện nó và đóng gói để dễ dàng tái sử dụng animation này nhé.

Make placeholder loading animation (continue)

Apply animation to table view

Đầu tiên, refactor code animate ở bài trước thành extension của UIView, UIColor như sau:
extension UIColor {

    class func backgroundGray() -> UIColor {
        return UIColor(red: 246.0 / 255, green: 247 / 255, blue: 248 / 255, alpha: 1)
    }

    class func lightGray() -> UIColor {
        return UIColor(red: 238.0 / 255, green: 238 / 255, blue: 238 / 255, alpha: 1)
    }

    class func darkGray() -> UIColor {
        return UIColor(red: 221.0 / 255, green: 221 / 255, blue: 221 / 255, alpha: 1)
    }

}
extension UIView {

    func startAnimationLoading() {
        let gradientLayer = CAGradientLayer()
        gradientLayer.frame = bounds
        gradientLayer.colors = [
            UIColor.backgroundGray().cgColor,
            UIColor.lightGray().cgColor,
            UIColor.darkGray().cgColor,
            UIColor.lightGray().cgColor,
            UIColor.backgroundGray().cgColor
        ]
        gradientLayer.startPoint = CGPoint(x: -0.85, y: 0)
        gradientLayer.endPoint = CGPoint(x: 1.15, y: 0)
        gradientLayer.locations = [-0.85, -0.85, 0, 0.15, 1.15]
        // Khởi tạo CABasicAnimation với keyPath muốn animate là `locations`
        let animation = CABasicAnimation(keyPath: "locations")
        // Giá trị `locations` bắt đầu animate
        animation.fromValue = gradientLayer.locations
        // Giá trị `locations` kết thúc animate
        animation.toValue = [0, 1, 1, 1.05, 1.15]
        // Lặp animation vô hạn
        animation.repeatCount = .infinity
        animation.fillMode = kCAFillModeForwards
        animation.isRemovedOnCompletion = false
        animation.duration = 1
        // Add animation cho gradient layer
        gradientLayer.add(animation, forKey: "what.ever.it.take")
        layer.addSublayer(gradientLayer)
    }

}
Và mỗi lần muốn animate view nào ta chỉ cần gọi function startAnimationLoading() là xong.
    override func viewDidLoad() {
        super.viewDidLoad()
        let myView = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 200))
        view.addSubview(myView)
        myView.startAnimationLoading()
    }
Tiếp theo, chúng ta tạo table view và các cell mẫu để animate. Ở đây, mình tạo file storyboard như này:

Table View Cell có các view tượng trưng cho ảnh avatar, username, status text.

Class NewsfeedViewController.swift
class NewsfeedViewController: UIViewController {
    
    @IBOutlet fileprivate weak var tableView: UITableView!
    fileprivate let numberOfPlaceHolderCells = 3
    
}

// MARK: - UITableViewDatasource

extension NewsfeedViewController: UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return numberOfPlaceHolderCells
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        return tableView.dequeueReusableCell(withIdentifier: "newsfeedCell", for: indexPath)
    }
    
}

// MARK: - UITableViewDelegate

extension NewsfeedViewController: UITableViewDelegate {
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        let tabBarHeight = tabBarController?.tabBar.frame.height ?? 0
        return (view.frame.height - tabBarHeight) / numberOfPlaceHolderCells
    }
    
}
Kết quả được table view static như hình.

Thêm function startLoading() vào class NewsfeedViewController để bắt đầu startAnimationLoading() từng subViews của contentView của các visibleCells.
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        startLoading()
    }

    private func startLoading() {
        tableView.visibleCells.forEach({
            $0.contentView.subviews.forEach({
                $0.startAnimationLoading()
            })
        })
    }
Tuy nhiên kết quả sẽ không được như ta mong muốn:

Các view animate một cách độc lập nên hiệu ứng lướt sẽ ngắn dài khác nhau, nhìn không giống như của Facebook, Youtube.

Add overlay view

Để giải quyết vấn đề trên, thay vì animate từng subview trong contentView của cell, chúng ta hãy thử cách khác.
Ý tưởng ở đây là animate cho contentView, sau đó tạo thêm một view màu trắng và trên view này "đục các lỗ" vừa bằng các subview của contentView.
Đồng thời set alpha của các subView bằng 0 để có thể nhìn "xuyên qua lỗ" và thấy được gradient layer ở dưới chuyển động.
Sửa lại function startLoading() trong NewsfeedViewController.swift.
 private func startLoading() {
        tableView.visibleCells.forEach({
            $0.contentView.startAnimationLoading()
        })
    }
Kết quả:

Tiếp theo, chúng ta tạo custom class OverlayView, kế thừa UIView và override function draw(_ rect: CGRect).
class OverlayView: UIView {
    
    override func draw(_ rect: CGRect) {
        super.draw(rect)
        let context = UIGraphicsGetCurrentContext()
        // Set màu cho context và đổ màu trắng toàn bộ view
        context?.setFillColor(UIColor.white.cgColor)
        context?.fill(bounds)
        // Set blend mode và màu trong suốt để chuẩn bị 'đục lỗ'
        context?.setBlendMode(.clear)
        context?.setFillColor(UIColor.clear.cgColor)
        // Tìm tất cả các subview của contentView, trừ chính overlay view và đổ màu trong suốt
        superview?.subviews.forEach({
            if $0 != self {
                context?.fill($0.frame)
            }
        })
    }
    
}
Trong extension UIView, thêm function addOverlayView().
 private func addOverlayView() {
  let overlayView = OverlayView()
        overlayView.frame = bounds
        overlayView.backgroundColor = .clear
        addSubview(overlayView)
    }
Rồi gọi nó ở cuối function startAnimationLoading().
    func startAnimationLoading() {
        ...
        layer.addSublayer(gradientLayer)
        addOverlayView()
    }
Và chúng ta cơ bản đã hoàn thành animation này. Việc còn lại là remove animation khi loading xong và hoàn thiện code.

Stop animation

Ở trên, chúng ta đã thêm gradient layer và overlay view để tạo ra animation. Nên muốn stop animation, cần remove 2 object này ra khỏi từng contentView.
Tuy nhiên, extension trong Swift không cho phép store property nên chúng ta sẽ sử dụng function objc_setAssociatedObjectobjc_getAssociatedObject.
  • objc_setAssociatedObject: set một property vào một object theo key.
  • objc_getAssociatedObject: get property theo key của một object.
Thêm 2 var string vào file extension.
var gradientLayerKey = "gradientLayer"
var overlayViewKey = "overlayView"
Thêm code để lưu gradientLayeroverlayView.
    func startAnimationLoading() {
        ...
        objc_setAssociatedObject(self, &gradientLayerKey, gradientLayer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    }
 private func addOverlayView() {
  ...
        objc_setAssociatedObject(self, &overlayViewKey, overlayView, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    }
Và function stopAnimationLoading().
 func stopAnimationLoading() {
        let overlayView = objc_getAssociatedObject(self, &overlayViewKey) as? OverlayView
        let gradientLayer = objc_getAssociatedObject(self, &gradientLayerKey) as? CAGradientLayer
  overlayView?.removeFromSuperview()
        gradientLayer?.removeFromSuperlayer()
    }
Implement function để start và stop animation trong NewsfeedViewController.swift.
    private func stopLoading() {
        tableView.visibleCells.forEach({
            $0.contentView.stopAnimationLoading()
        })
    }

    @IBAction func startButtonTapped(_ sender: Any) {
        startLoading()
    }

    @IBAction func stopButtonTapped(_ sender: Any) {
        stopLoading()
    }

Conclusion

Chỉ với một số kiến thức cơ bản về CAGradientLayer, CoreAnimationUIView, chúng ta đã có thể làm được loading animation rất đẹp, thích hợp cho tableView, collectionView, giúp cải thiện UI/UX.
Link final project: https://github.com/oNguyenXuanThanh/StudyReport092018

Comments

Popular posts from this blog

MVVM và VIPER: Con đường trở thành Senior

Trong bài viết trước chúng ta đã tìm hiểu về MVC và MVP để ứng dụng cho một iOS App đơn giản. Bài này chúng ta sẽ tiếp tục ứng dụng 2 mô hình MVVM và VIPER . Nhắc lại là ứng dụng của chúng ta cụ thể khi chạy sẽ như sau: Source code đầy đủ cho tất cả mô hình MVC, MVP, MVVM và VIPER các bạn có thể download tại đây . MVVM MVVM có thể nói là mô hình kiến trúc được rất nhiều các cư dân trong cộng đồng ưa chuộng. Điểm tinh hoa của kiến trúc này là ở ViewModel , mặc dù rất giống với Presenter trong MVP tuy nhiên có 2 điều làm nên tên tuổi của kiến trúc này đó là: ViewModel không hề biết gì về View , một ViewModel có thể được sử dụng cho nhiều View (one-to-many). ViewModel sử dụng Observer design pattern để liên lạc với View (thường được gọi là binding data , có thể là 1 chiều hoặc 2 chiều tùy nhu cầu ứng dụng). Chính đặc điểm này MVVM thường được phối hợp với các thư viện hỗ trợ Reactive Programming hay Event/Data Stream , đây là triết lý lập trình hiện đại và hiệu

Alamofire vs URLSession

Alamofire vs URLSession: a comparison for networking in Swift Alamofire and URLSession both help you to make network requests in Swift. The URLSession API is part of the foundation framework, whereas Alamofire needs to be added as an external dependency. Many  developers  doubt  whether it’s needed to include an extra dependency on something basic like networking in Swift. In the end, it’s perfectly doable to implement a networking layer with the great URLSession API’s which are available nowadays. This blog post is here to compare both frameworks and to find out when to add Alamofire as an external dependency. Build better iOS apps faster Looking for a great mobile CI/CD solution that has tons of iOS-specific tools, smooth code signing, and even real device testing? Learn more about Bitrise’s iOS specific solutions! This shows the real power of Alamofire as the framework makes a lot of things easier. What is Alamofire? Where URLSession can be found within the s

Fileprivate vs private: Giải thích sự khác biệt

Fileprivate vs private in Swift: The differences explained Fileprivate and private are part of the access control modifiers in Swift. These keywords, together with internal, public, and open, make it possible to restrict access to parts of your code from code in other source files and modules. The private access level is the lowest and most restrictive level whereas open access is the highest and least restrictive. The documentation of Swift will explain all access levels in detail to you, but in this blog post, I’m going to explain the differences between two close friends: fileprivate and private. Build better iOS apps faster Looking for a great mobile CI/CD solution that has tons of iOS-specific tools, smooth code signing, and even real device testing? Learn more about Bitrise’s iOS specific solutions! Open access is the highest (least restrictive) access level and private access is the lowest (most restrictive) access level. This will improve readability and mak