[UIKit] Custom SkelletonView

Junyoung Park·2022년 11월 26일
0

UIKit

목록 보기
105/142
post-thumbnail
post-custom-banner

Skeleton Loader Shimmer Effect (Swift/iOS)

Custom SkelletonView

구현 목표

  • 커스텀 스켈레톤 뷰 구현

구현 태스크

  • 실제 UI를 표현한 커스텀 테이블 뷰 셀 구현
  • 로딩 중을. 표현한 커스텀 스켈레톤 테이블 뷰 셀 구현
  • 스켈레톤 애니메이션 프로토콜 구현

핵심 코드

func makeAnimationGroup(previousGroup: CAAnimationGroup? = nil) -> CAAnimationGroup {
        let animationDuration: CFTimeInterval = 1.5
        
        let animation1 = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.backgroundColor))
        animation1.fromValue = UIColor.lightGray.cgColor
        animation1.toValue = UIColor.darkGray.cgColor
        animation1.duration = animationDuration
        animation1.beginTime = 0.0
        
        let animation2 = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.backgroundColor))
        animation2.fromValue = UIColor.darkGray.cgColor
        animation2.toValue = UIColor.lightGray.cgColor
        animation2.duration = animationDuration
        animation2.beginTime = animation1.beginTime + animation1.duration
        
        let group = CAAnimationGroup()
        group.animations = [animation1, animation2]
        group.repeatCount = .greatestFiniteMagnitude
        group.duration = animation2.beginTime + animation2.duration
        group.isRemovedOnCompletion = false
        
        if let previousGroup = previousGroup {
            group.beginTime = previousGroup.beginTime + 0.2
        }
        
        return group
    }
  • 스켈레톤 뷰의 shimmer 이펙트를 주기 위한 커스텀 애니메이션 구현
  • 동일한 애니메이션 그룹을 생성하는 함수 → SkeletonLoadable이라는 별도의 프로토콜을 따르는 익스텐션 함수로 특정 테이블 뷰 셀이 해당 프로토콜을 따를 때 자동으로 사용 가능하도록 구현
  • 시작값과 끝값 사이를 주기적으로 반복, 애니메이션 효과 주기
override func layoutSubviews() {
        super.layoutSubviews()
        photoImageLayer.frame = photoImageView.bounds
        photoImageLayer.cornerRadius = photoImageView.bounds.height / 2
        photoTitleLabelLayer.frame = photoTitleLabel.bounds
        photoTitleLabelLayer.cornerRadius = photoTitleLabel.bounds.height / 2
    }
  • 스켈레톤 뷰를 담당하는 테이블 뷰 셀 내에서 프레임 조정
  • 둥근 모서리를 주기 위하여 cornerRadius 조정
  • 해당 스켈레톤 뷰가 위치할 곳은 들어오는 컴포넌트들의 위치 바로 위이기 때문에 bounds 자체가 프레임
private func setUI() {
        contentView.addSubview(photoTitleLabel)
        contentView.addSubview(photoImageView)
        photoTitleLabel.translatesAutoresizingMaskIntoConstraints = false
        photoImageView.translatesAutoresizingMaskIntoConstraints = false
        
        let photoImageViewConstraints = [
            photoImageView.leadingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.leadingAnchor, constant: 5),
            photoImageView.widthAnchor.constraint(equalToConstant: contentView.frame.width / 3.0),
            photoImageView.heightAnchor.constraint(greaterThanOrEqualToConstant: contentView.frame.width / 3.0),
            photoImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
        ]
        
        NSLayoutConstraint.activate(photoImageViewConstraints)
        
        let photoTitleLabelConstraints = [
            photoTitleLabel.topAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.topAnchor, constant: 5),
            photoTitleLabel.leadingAnchor.constraint(equalTo: photoImageView.trailingAnchor, constant: 5),
            photoTitleLabel.trailingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.trailingAnchor, constant: -5),
            photoTitleLabel.bottomAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.bottomAnchor, constant: -5),
            photoTitleLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: contentView.frame.width / 3.0)
        ]
        
        NSLayoutConstraint.activate(photoTitleLabelConstraints)
    }
  • 스켈레톤 뷰와 별개로 실제 데이터를 통해 UI를 동적으로 그리는 테이블 뷰 셀 내의 레이아웃 설정
  • contentView 자체와 오토 레이아웃을 통해 라벨 텍스트가 길어질 수록 동적으로 사이즈가 늘어남
  • 이미지의 최소 사이즈와 라벨 텍스트의 최소 사이즈가 다를 수 있기 때문에 각 height의 최소값을 설정
  • 라벨의 numberOfLines가 0일 때에야 멀티 라인 가능

소스 코드

import UIKit
import Combine

class SkelletonViewController: UIViewController {
    private lazy var tableView: UITableView = {
        let tableView = UITableView()
        tableView.delegate = self
        tableView.dataSource = self
        tableView.register(CustomTableViewCell.self, forCellReuseIdentifier: CustomTableViewCell.identifier)
        tableView.register(SkelletonTableViewCell.self, forCellReuseIdentifier: SkelletonTableViewCell.identifier)
        return tableView
    }()
    private lazy var refreshButton: UIBarButtonItem = {
        let button = UIBarButtonItem(barButtonSystemItem: .refresh, target: self, action: #selector(didTapRefresh))
        return button
    }()
    private let viewModel = SkelletonViewModel()
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        tableView.frame = view.bounds
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
        bind()
    }
    
    private func setUI() {
        view.backgroundColor = .systemBackground
        view.addSubview(tableView)
        navigationItem.rightBarButtonItem = refreshButton
    }
    
    private func bind() {
        viewModel
            .photos
            .sink { [weak self] _ in
                self?.tableView.reloadData()
            }
            .store(in: &cancellables)
    }
    
    @objc private func didTapRefresh() {
        viewModel.loaded.toggle()
        tableView.reloadData()
    }
}

extension SkelletonViewController: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.photos.value.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        if viewModel.loaded {
            guard let cell = tableView.dequeueReusableCell(withIdentifier: CustomTableViewCell.identifier, for: indexPath) as? CustomTableViewCell else { return UITableViewCell() }
            let model = viewModel.photos.value[indexPath.row]
            cell.configure(with: model)
            return cell
        } else {
            guard let cell = tableView.dequeueReusableCell(withIdentifier: SkelletonTableViewCell.identifier, for: indexPath) as? SkelletonTableViewCell else {
                return UITableViewCell()
            }
            DispatchQueue.main.async {
                cell.layoutSubviews()
            }
            return cell
        }
    }
}

  • 커스텀 테이블 뷰 구현
  • 두 종류의 셀 등록

import Foundation
import Combine

class SkelletonViewModel {
    let photos: CurrentValueSubject<[PhotoModel], Never> = .init([])
    var loaded: Bool = true
    
    init() {
        addMockData()
    }
    
    private func addMockData() {
        var photos = [PhotoModel]()
        for x in 10...200 {
            let mockTitle = "title_\(x)"
            let title = Array(repeating: mockTitle, count: x).reduce("", +)
            let imageName = "image_\(x)"
            let photo = PhotoModel(title: title, imageName: imageName)
            photos.append(photo)
        }
        self.photos.send(photos)
    }
}
  • 더미 데이터를 사용하고 있는 뷰 모델
  • 실제 네트워크 패치를 통해 데이터를 얻어올 부분
  • 현 시점에서는 뷰 컨트롤러의 리프레시 버튼을 누를 때마다 해당 불리언 변수 값이 토글, 해당 값에 따라 불러오는 셀 타입이 변경
import UIKit

class CustomTableViewCell: UITableViewCell {
    
    static let identifier = "CustomTableViewCell"
    
    private let photoImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.image = UIImage(systemName: "person")
        imageView.tintColor = .label
        imageView.clipsToBounds = true
        return imageView
    }()
    private let photoTitleLabel: UILabel = {
        let label = UILabel()
        label.font = .preferredFont(forTextStyle: .body)
        label.textAlignment = .center
        label.textColor = .label
        label.numberOfLines = 0
        return label
    }()
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        photoImageView.layer.cornerRadius = photoImageView.frame.height / 2.0
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        photoTitleLabel.text = nil
        photoImageView.image = nil
    }
    
    private func setUI() {
        contentView.addSubview(photoTitleLabel)
        contentView.addSubview(photoImageView)
        photoTitleLabel.translatesAutoresizingMaskIntoConstraints = false
        photoImageView.translatesAutoresizingMaskIntoConstraints = false
        
        let photoImageViewConstraints = [
            photoImageView.leadingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.leadingAnchor, constant: 5),
            photoImageView.widthAnchor.constraint(equalToConstant: contentView.frame.width / 3.0),
            photoImageView.heightAnchor.constraint(greaterThanOrEqualToConstant: contentView.frame.width / 3.0),
            photoImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
        ]
        
        NSLayoutConstraint.activate(photoImageViewConstraints)
        
        let photoTitleLabelConstraints = [
            photoTitleLabel.topAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.topAnchor, constant: 5),
            photoTitleLabel.leadingAnchor.constraint(equalTo: photoImageView.trailingAnchor, constant: 5),
            photoTitleLabel.trailingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.trailingAnchor, constant: -5),
            photoTitleLabel.bottomAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.bottomAnchor, constant: -5),
            photoTitleLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: contentView.frame.width / 3.0)
        ]
        
        NSLayoutConstraint.activate(photoTitleLabelConstraints)
    }
    
    
    
    func configure(with model: PhotoModel) {
        photoTitleLabel.text = model.title
        photoImageView.image = UIImage(systemName: "person.circle")
    }

}
  • 커스텀 테이블 뷰 셀
  • 동적으로 텍스트 길이에 따라 라벨 크기가 변경
import UIKit

class SkelletonTableViewCell: UITableViewCell {
    static let identifier = "SkelletonTableViewCell"
    
    private let photoImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.clipsToBounds = true
        return imageView
    }()
    private let photoImageLayer = CAGradientLayer()
    private let photoTitleLabel: UILabel = {
        let label = UILabel()
        label.numberOfLines = 0
        return label
    }()
    private let photoTitleLabelLayer = CAGradientLayer()
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        photoImageLayer.frame = photoImageView.bounds
        photoImageLayer.cornerRadius = photoImageView.bounds.height / 2
        photoTitleLabelLayer.frame = photoTitleLabel.bounds
        photoTitleLabelLayer.cornerRadius = photoTitleLabel.bounds.height / 2
    }
    
    private func setUI() {
        photoImageLayer.startPoint = CGPoint(x: 0, y: 0.5)
        photoImageLayer.endPoint = CGPoint(x: 1, y: 0.5)
        photoImageView.layer.addSublayer(photoImageLayer)
        
        photoTitleLabelLayer.startPoint = CGPoint(x: 0, y: 0.5)
        photoTitleLabelLayer.endPoint = CGPoint(x: 1, y: 0.5)
        photoTitleLabel.layer.addSublayer(photoTitleLabelLayer)
        
        let imageGroup = makeAnimationGroup()
        imageGroup.beginTime = 0.0
        photoImageLayer.add(imageGroup, forKey: "backgroundColor")
        
        let titleGroup = makeAnimationGroup(previousGroup: imageGroup)
        photoTitleLabelLayer.add(titleGroup, forKey: "backgroundColor")
        
        contentView.addSubview(photoTitleLabel)
        contentView.addSubview(photoImageView)
        photoTitleLabel.translatesAutoresizingMaskIntoConstraints = false
        photoImageView.translatesAutoresizingMaskIntoConstraints = false

        let photoImageViewConstraints = [
            photoImageView.leadingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.leadingAnchor, constant: 5),
            photoImageView.widthAnchor.constraint(equalToConstant: contentView.frame.width / 3.0),
            photoImageView.heightAnchor.constraint(greaterThanOrEqualToConstant: contentView.frame.width / 3.0),
            photoImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
        ]

        NSLayoutConstraint.activate(photoImageViewConstraints)

        let photoTitleLabelConstraints = [
            photoTitleLabel.topAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.topAnchor, constant: 5),
            photoTitleLabel.leadingAnchor.constraint(equalTo: photoImageView.trailingAnchor, constant: 5),
            photoTitleLabel.trailingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.trailingAnchor, constant: -5),
            photoTitleLabel.bottomAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.bottomAnchor, constant: -5),
            photoTitleLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: contentView.frame.width / 3.0)
        ]

        NSLayoutConstraint.activate(photoTitleLabelConstraints)
    }
}

extension SkelletonTableViewCell: SkeletonLoadable { }
  • 커스텀 스켈레톤 뷰 셀
  • 커스텀 테이블 뷰 셀과 동일한 위치에 컴포넌트를 사용, 해당 위치에 레이어를 통해 로딩되고 있는 듯한 애니메이션 적용
import UIKit

protocol SkeletonLoadable {}

extension SkeletonLoadable {
    func makeAnimationGroup(previousGroup: CAAnimationGroup? = nil) -> CAAnimationGroup {
        let animationDuration: CFTimeInterval = 1.5
        
        let animation1 = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.backgroundColor))
        animation1.fromValue = UIColor.lightGray.cgColor
        animation1.toValue = UIColor.darkGray.cgColor
        animation1.duration = animationDuration
        animation1.beginTime = 0.0
        
        let animation2 = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.backgroundColor))
        animation2.fromValue = UIColor.darkGray.cgColor
        animation2.toValue = UIColor.lightGray.cgColor
        animation2.duration = animationDuration
        animation2.beginTime = animation1.beginTime + animation1.duration
        
        let group = CAAnimationGroup()
        group.animations = [animation1, animation2]
        group.repeatCount = .greatestFiniteMagnitude
        group.duration = animation2.beginTime + animation2.duration
        group.isRemovedOnCompletion = false
        
        if let previousGroup = previousGroup {
            group.beginTime = previousGroup.beginTime + 0.2
        }
        
        return group
    }
}
  • 프로토콜 익스테션을 통해 해당 프로토콜을 상속한 모든 컴포넌트에서 해당 애니메이션 생성 함수 사용 가능
import Foundation

struct PhotoModel {
    let title: String
    let imageName: String
}
  • 커스텀 데이터 모델

구현 화면

실제로 해당 스켈레톤 뷰가 사용될 때에는 어떤 식으로 사용될까? 여러 종류의 데이터가 뷰 모델을 통해 들어오고, 각 데이터를 통해 한 개의 셀을 configure하고, 그 과정에서 발생하는 비동기 패치 이벤트 시간 동안만 스켈레톤 뷰를 보여주는 게 맞을 것이다. 즉 현재 구현한 것처럼 두 개의 셀을 테이블 뷰의 데이터 소스 함수 단에서 교차하는 것보다도, 동일한 테이블 뷰 셀 내에서 데이터를 패치하는 과정에서 실제 UI를 보여줄 것인지, 스켈레톤 뷰 애니메이션을 보여줄지 결정하는 게 아닐까?

profile
JUST DO IT
post-custom-banner

0개의 댓글