I created a endless carousel based on a UICollectionView and was happy with the result. When testing on an iPhone 16 simulator, I had no issues. On the iPhone 16 Pro Max simulator, I encountered a problem: when opening the carousel screen, the selected cell didn't display a colored glowView. The color only appears after interacting with the carousel.
I found the probable source of the error:
distance < 1
in the func updateSelection(), but I can't fix it.
Has anyone encountered something similar?
My code:
final class AvatarCarouselCell: UICollectionViewCell {
static let reuseId = "AvatarCarouselCell"
private let containerView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .gray90
view.clipsToBounds = true
view.isUserInteractionEnabled = false
return view
}()
private let glowView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .clear
view.isUserInteractionEnabled = false
return view
}()
private let imageView: UIImageView = {
let view = UIImageView()
view.translatesAutoresizingMaskIntoConstraints = false
view.contentMode = .scaleAspectFit
view.clipsToBounds = true
view.isUserInteractionEnabled = false
return view
}()
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(containerView)
contentView.addSubview(glowView)
contentView.addSubview(imageView)
NSLayoutConstraint.activate([
containerView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
containerView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
containerView.widthAnchor.constraint(equalToConstant: 134),
containerView.heightAnchor.constraint(equalToConstant: 134),
glowView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
glowView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
glowView.widthAnchor.constraint(equalTo: containerView.widthAnchor),
glowView.heightAnchor.constraint(equalTo: containerView.heightAnchor),
imageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
imageView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
imageView.widthAnchor.constraint(equalToConstant: 88),
imageView.heightAnchor.constraint(equalToConstant: 88)
])
}
override func prepareForReuse() {
super.prepareForReuse()
imageView.image = nil
setSelected(false, selectedColor: .clear, defaultColor: .clear)
}
override func layoutSubviews() {
super.layoutSubviews()
glowView.layer.shadowPath = UIBezierPath(
ovalIn: glowView.bounds
).cgPath
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
let radius = 134 / 2
containerView.layer.cornerRadius = CGFloat(radius)
glowView.layer.cornerRadius = CGFloat(radius)
}
func configure(imageName: String) {
imageView.image = UIImage(named: imageName)
}
func setSelected(_ isSelected: Bool,
selectedColor: UIColor,
defaultColor: UIColor) {
if isSelected {
glowView.layer.shadowColor = selectedColor.cgColor
glowView.layer.shadowRadius = 4
glowView.layer.shadowOpacity = 0.9
glowView.layer.shadowOffset = .zero
} else {
glowView.layer.shadowOpacity = 0
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
final class AvatarCarouselCollectionView: UIViewController, UICollectionViewDelegate {
private let avatars: [String]
private let selectedColor: UIColor
private let defaultColor: UIColor
private var selectedRealIndex: Int
private let onIndexChanged: ((Int) -> Void)?
private var didSetInitialSelection = false
private let minScale: CGFloat = 0.7
private let infiniteItemsCount = 10_000
private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.itemSize = CGSize(width: 140, height: 140)
layout.minimumLineSpacing = 4
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .gray100
view.showsHorizontalScrollIndicator = false
view.decelerationRate = .fast
view.dataSource = self
view.delegate = self
view.register(AvatarCarouselCell.self, forCellWithReuseIdentifier: AvatarCarouselCell.reuseId)
return view
}()
init(avatars: [String],
selectedIndex: Int,
selectedColor: UIColor,
defaultColor: UIColor,
onIndexChanged: ((Int) -> Void)?) {
self.avatars = avatars
self.selectedRealIndex = max(0, min(selectedIndex, avatars.count - 1))
self.selectedColor = selectedColor
self.defaultColor = defaultColor
self.onIndexChanged = onIndexChanged
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError()
}
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .gray100
setupConstraints()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
updateSectionInsets()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
scrollToInitialSelectionIfNeeded()
}
func setSelectedIndex(_ index: Int, animated: Bool) {
let clamped = clampRealIndex(index)
guard clamped != selectedRealIndex else { return }
selectedRealIndex = clamped
scrollToRealIndex(clamped, animated: animated)
if !animated {
collectionView.layoutIfNeeded()
updateSelection()
}
}
func currentSelectedIndex() -> Int {
selectedRealIndex
}
// MARK: - Helpers
private func setupConstraints() {
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
private func updateSectionInsets() {
guard let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout else { return }
let inset = (view.bounds.width - layout.itemSize.width) / 2
layout.sectionInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset)
}
private var itemWidth: CGFloat {
let layout = collectionView.collectionViewLayout as! UICollectionViewFlowLayout
return layout.itemSize.width + layout.minimumLineSpacing
}
private func realIndex(forInfiniteIndex index: Int) -> Int {
guard !avatars.isEmpty else { return 0 }
return index % avatars.count
}
private func clampRealIndex(_ index: Int) -> Int {
guard !avatars.isEmpty else { return 0 }
return max(0, min(index, avatars.count - 1))
}
// MARK: - Initial scroll
private func scrollToInitialSelectionIfNeeded() {
guard !didSetInitialSelection else {
updateSelection()
return
}
didSetInitialSelection = true
collectionView.layoutIfNeeded()
scrollToRealIndex(selectedRealIndex, animated: false)
collectionView.layoutIfNeeded()
updateSelection()
}
private func scrollToRealIndex(_ realIndex: Int, animated: Bool) {
guard !avatars.isEmpty else { return }
let middle = infiniteItemsCount / 2
let base = middle - (middle % avatars.count)
let item = base + realIndex
collectionView.scrollToItem(
at: IndexPath(item: item, section: 0),
at: .centeredHorizontally,
animated: animated
)
}
// MARK: - Selection logic
private func updateSelection() {
guard !avatars.isEmpty else { return }
let centerX = collectionView.contentOffset.x + collectionView.bounds.width / 2
for case let cell as AvatarCarouselCell in collectionView.visibleCells {
let distance = abs(cell.center.x - centerX)
scaleEffect(to: cell, distance: distance)
cell.setSelected(distance < 1, selectedColor: selectedColor, defaultColor: defaultColor)
}
guard
let cell = centeredCell(),
let indexPath = collectionView.indexPath(for: cell)
else { return }
let newReal = realIndex(forInfiniteIndex: indexPath.item)
updateSelectedIndex(newReal)
}
private func centeredCell() -> AvatarCarouselCell? {
let centerX = collectionView.contentOffset.x + collectionView.bounds.width / 2
return collectionView.visibleCells
.compactMap { $0 as? AvatarCarouselCell }
.min { abs($0.center.x - centerX) < abs($1.center.x - centerX) }
}
private func scaleEffect(to cell: AvatarCarouselCell, distance: CGFloat) {
let scale = max(minScale, 1 - distance / (collectionView.bounds.width / 2))
cell.transform = CGAffineTransform(scaleX: scale, y: scale)
}
private func updateSelectedIndex(_ index: Int) {
guard index != selectedRealIndex else { return }
selectedRealIndex = index
onIndexChanged?(index)
}
}
// MARK: - DataSource
extension AvatarCarouselCollectionView: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
infiniteItemsCount
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: AvatarCarouselCell.reuseId,
for: indexPath
) as! AvatarCarouselCell
let realIndex = realIndex(forInfiniteIndex: indexPath.item)
cell.configure(imageName: avatars[realIndex])
let isSelected = realIndex == selectedRealIndex
cell.setSelected(isSelected,
selectedColor: selectedColor,
defaultColor: defaultColor)
return cell
}
}
// MARK: - ScrollViewDelegate
extension AvatarCarouselCollectionView: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
updateSelection()
}
func scrollViewWillEndDragging(_ scrollView: UIScrollView,
withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer) {
let index = round(targetContentOffset.pointee.x / itemWidth)
targetContentOffset.pointee.x = index * itemWidth
}
}