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)
}
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를 보여줄 것인지, 스켈레톤 뷰 애니메이션을 보여줄지 결정하는 게 아닐까?