I have a collectionview with a custom flowlayout and a custom collectionview cell (no storyboards). The custom cell has a CAGradientLayer on a background view. When coming back from suspended state or on traitcollection change this layer is rendered incorrectly (see image:) ) It should be the full width of the cell. Also when scrolling to off screen items below, the gradient layer isn't rendered at all?
Rotating the device once, or scrolling resolves the issue ... I'm not sure if this is solvable in the custom cell class or in the collectionview viewcontroller. Reuse issue? Any help greatly appreciated!
NOTE: Universal app, both ipad and iphone, also split screen compatible.
The cell class
class NormalProjectCell: UICollectionViewCell, SelfConfiguringProjectCell {
//MARK: - Properties
let titleLabel = ProjectTitleLabel(withTextAlignment: .center, andFont: UIFont.preferredFont(forTextStyle: .title3), andColor: .label)
let lastEditedLabel = ProjectTitleLabel(withTextAlignment: .center, andFont: UIFont.preferredFont(forTextStyle: .caption1), andColor: .secondaryLabel)
let imageView = ProjectImageView(frame: .zero)
var stackView = UIStackView()
var backgroundMaskedView = UIView()
//MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
self.layer.cornerRadius = 35
let seperator = Separator(frame: .zero)
stackView = UIStackView(arrangedSubviews: [seperator, titleLabel, lastEditedLabel, imageView])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.distribution = .fillProportionally
stackView.spacing = 5
stackView.setCustomSpacing(10, after: lastEditedLabel)
stackView.insertSubview(backgroundMaskedView, at: 0)
contentView.addSubview(stackView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
//MARK: - Layout
override func layoutSubviews() {
super.layoutSubviews()
NSLayoutConstraint.activate([
titleLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: 20),
stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
stackView.topAnchor.constraint(equalTo: contentView.topAnchor),
stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
backgroundMaskedView.translatesAutoresizingMaskIntoConstraints = false
backgroundMaskedView.backgroundColor = .tertiarySystemBackground
backgroundMaskedView.pinToEdges(of: stackView)
let gradientMaskLayer = CAGradientLayer()
gradientMaskLayer.frame = backgroundMaskedView.bounds
gradientMaskLayer.colors = [UIColor.systemPurple.cgColor, UIColor.clear.cgColor]
gradientMaskLayer.locations = [0, 0.4]
backgroundMaskedView.layer.mask = gradientMaskLayer
}
//MARK: - Configure
func configure(with project: ProjectsController.Project) {
titleLabel.text = project.title
lastEditedLabel.text = project.lastEdited.customMediumToString
imageView.image = Bundle.getProjectImage(project: project)
}
}
and the viewcontroller with the collectionView:
class ProjectsViewController: UIViewController {
//MARK: - Types
enum Section: CaseIterable {
case normal
}
//MARK: - Properties
let projectsController = ProjectsController()
var collectionView: UICollectionView!
var dataSource: UICollectionViewDiffableDataSource<Section, Project>!
var lastScrollPosition: CGFloat = 0
var isSearching = false
let searchController = UISearchController()
//MARK: - ViewController Methods
override func viewDidLoad() {
super.viewDidLoad()
configureViewController()
configureSearchController()
configureCollectionView()
createDataSource()
updateData(on: projectsController.filteredProjects())
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if isSearching {
isSearching.toggle()
searchController.searchBar.text = ""
searchController.resignFirstResponder()
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
searchController.searchBar.searchTextField.attributedPlaceholder = NSAttributedString(string: "Title or details text ...",
attributes: [NSAttributedString.Key.foregroundColor: UIColor.secondaryLabel])
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
collectionView.collectionViewLayout = UICollectionView.createFlexibleFlowLayout(in: view)
}
//MARK: - DataSource
func createDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, Project>(collectionView: collectionView) { (collectionView, indexPath, project) in
return self.configure(NormalProjectCell.self, with: project, for: indexPath)
}
}
func updateData(on projects: [Project]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Project>()
snapshot.appendSections([Section.normal])
snapshot.appendItems(projects)
//apply() is safe to call from a background queue!
self.dataSource.apply(snapshot, animatingDifferences: true)
}
///Configure any type of cell that conforms to selfConfiguringProjectCell!
func configure<T: SelfConfiguringProjectCell>(_ cellType: T.Type, with project: Project, for indexPath: IndexPath) -> T {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellType.reuseIdentifier, for: indexPath) as? T else {
fatalError("Unable to dequeue \(cellType)")
}
cell.configure(with: project)
return cell
}
//MARK: - Actions
@objc func addButtonTapped() {
let project = Project()
let viewController = ProjectDetailsViewController(withProject: project)
viewController.delegate = self
navigationController?.pushViewController(viewController, animated: true)
}
@objc private func tapAndHoldCell(recognizer: UILongPressGestureRecognizer) {
if recognizer.state == .ended {
guard let indexPath = collectionView.indexPathForItem(at: recognizer.location(in: self.collectionView)),
let project = dataSource?.itemIdentifier(for: indexPath) else {
return
}
let viewController = ProjectDetailsViewController(withProject: project)
viewController.delegate = self
navigationController?.pushViewController(viewController, animated: true)
}
}
@objc private func swipeFromRightOnCell(recognizer: UISwipeGestureRecognizer) {
if recognizer.state == .ended {
guard let indexPath = collectionView.indexPathForItem(at: recognizer.location(in: self.collectionView)),
let cell = collectionView.cellForItem(at: indexPath),
let project = dataSource?.itemIdentifier(for: indexPath) else {
return
}
let overlay = ProjectCellDeletionOverlay(frame: CGRect(x: cell.bounds.width, y: 0, width: 0, height: cell.bounds.height))
cell.addSubview(overlay)
UIView.animate(withDuration: 0.70, animations: {
overlay.backgroundColor = UIColor.red.withAlphaComponent(0.60)
overlay.frame = CGRect(x: cell.bounds.width / 2, y: 0, width: cell.bounds.width / 2, height: cell.bounds.height)
}) { _ in
self.presentProjectAlertOnMainThread(withTitle: "Delete this Project?",
andMessage: "Are you sure?\nThis cannot be undone!\nAll associated notes will also be deleted!",
andDismissButtonTitle: "Cancel",
andConfirmButtonTitle: "Delete!",
completion: { success in
if success {
UIView.animate(withDuration: 1.40, animations: {
overlay.frame = CGRect(x: 0, y: 0, width: cell.bounds.width, height: cell.bounds.height)
cell.alpha = 0
}) { _ in
self.delete(project)
overlay.removeFromSuperview()
}
} else {
UIView.animate(withDuration: 1.5, animations: {
overlay.frame = CGRect(x: cell.bounds.width, y: 0, width: 0, height: cell.bounds.height)
overlay.alpha = 0
}) { _ in
overlay.removeFromSuperview()
}
}
})
}
}
}
///Will show an overlay view with help text on the app
@objc private func showHelpView() {
let helpViewController = AppHelpViewController(with: HelpViewDisplayTextFor.projects)
helpViewController.modalTransitionStyle = .flipHorizontal
helpViewController.modalPresentationStyle = .fullScreen
present(helpViewController, animated: true)
}
///Will show a menu with several options
@objc private func showMenu() {
}
//MARK: - UI & Layout
private func configureViewController() {
view.backgroundColor = .systemPurple
title = "Projects"
navigationController?.navigationBar.prefersLargeTitles = false
let menu = UIBarButtonItem(image: ProjectImages.BarButton.menu, style: .plain, target: self, action: #selector(showMenu))
let add = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addButtonTapped))
navigationItem.leftBarButtonItems = [menu, add]
let questionMark = UIBarButtonItem(image: ProjectImages.BarButton.questionmark, style: .plain, target: self, action: #selector(showHelpView))
navigationItem.rightBarButtonItem = questionMark
}
private func configureCollectionView() {
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: UICollectionView.createFlexibleFlowLayout(in: view))
collectionView.delegate = self
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.backgroundColor = .clear
view.addSubview(collectionView)
collectionView.register(NormalProjectCell.self, forCellWithReuseIdentifier: NormalProjectCell.reuseIdentifier)
let tapAndHold = UILongPressGestureRecognizer(target: self, action: #selector(tapAndHoldCell))
tapAndHold.minimumPressDuration = 0.3
collectionView.addGestureRecognizer(tapAndHold)
let swipeFromRight = UISwipeGestureRecognizer(target: self, action: #selector(swipeFromRightOnCell) )
swipeFromRight.direction = UISwipeGestureRecognizer.Direction.left
collectionView.addGestureRecognizer(swipeFromRight)
}
private func configureSearchController() {
searchController.searchResultsUpdater = self
searchController.obscuresBackgroundDuringPresentation = false
navigationItem.searchController = searchController
//CollectionView under searchbar fix ???
searchController.extendedLayoutIncludesOpaqueBars = true
// searchController.edgesForExtendedLayout = .top
}
}
//MARK: - Ext CollectionView Delegate
extension ProjectsViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let project = dataSource?.itemIdentifier(for: indexPath) else { return }
ProjectsController.activeProject = project
let loadingView = showLoadingView(for: project)
let viewController = SplitOrFlipContainerController()
UIView.animate(withDuration: 1.5, animations: {
loadingView.alpha = 1
}) { (complete) in
self.dismiss(animated: false) {
self.present(viewController, animated: false)
}
}
}
}
//MARK: - Ext Search Results & Bar
extension ProjectsViewController: UISearchResultsUpdating {
func updateSearchResults(for searchController: UISearchController) {
guard let filter = searchController.searchBar.text, filter.isNotEmpty else {
isSearching = false
updateData(on: projectsController.filteredProjects())
return
}
isSearching = true
updateData(on: projectsController.filteredProjects(with: filter.lowercased()))
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
lastScrollPosition = scrollView.contentOffset.y
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if lastScrollPosition < scrollView.contentOffset.y {
navigationItem.hidesSearchBarWhenScrolling = true
} else if lastScrollPosition > scrollView.contentOffset.y {
navigationItem.hidesSearchBarWhenScrolling = false
}
}
}
//MARK: - ProjectHandler
extension ProjectsViewController: ProjectHandler {
internal func save(_ project: Project, withImage image: UIImage?) {
//call save and update the snapshot
projectsController.save(project, withImage: image)
updateData(on: projectsController.filteredProjects())
collectionView.reloadData()
}
internal func delete(_ project: Project) {
//call delete and update the snapshot
projectsController.delete(project)
updateData(on: projectsController.filteredProjects())
}
}
And the flow layout:
extension UICollectionView {
///Flow layout with minimum 2 items across, with padding and spacing
static func createFlexibleFlowLayout(in view: UIView) -> UICollectionViewFlowLayout {
let width = view.bounds.width
let padding: CGFloat
let minimumItemSpacing: CGFloat
let availableWidth: CGFloat
let itemWidth: CGFloat
if view.traitCollection.verticalSizeClass == .compact {
print("//iPhones landscape")
padding = 12
minimumItemSpacing = 12
availableWidth = width - (padding * 2) - (minimumItemSpacing * 3)
itemWidth = availableWidth / 4
} else if view.traitCollection.horizontalSizeClass == .compact && view.traitCollection.verticalSizeClass == .regular {
print("//iPhones portrait")
padding = 12
minimumItemSpacing = 12
availableWidth = width - (padding * 2) - (minimumItemSpacing)
itemWidth = availableWidth / 2
} else {
print("//iPads")
padding = 24
minimumItemSpacing = 24
availableWidth = width - (padding * 2) - (minimumItemSpacing * 3)
itemWidth = availableWidth / 4
}
let flowLayout = UICollectionViewFlowLayout()
flowLayout.sectionInset = UIEdgeInsets(top: padding, left: padding, bottom: padding, right: padding)
flowLayout.itemSize = CGSize(width: itemWidth, height: itemWidth + 40)
flowLayout.sectionHeadersPinToVisibleBounds = true
return flowLayout
}
}
Thanks to some help from @nemecek_filip at the HWS forums, I got it solved! Different approach to the gradient using a custom gradient view!
Hereby the changed and working code:
collectionView cell:
//
// NormalProjectCell.swift
//
import UIKit
class NormalProjectCell: UICollectionViewCell, SelfConfiguringProjectCell {
//MARK: - Properties
let titleLabel = ProjectTitleLabel(withTextAlignment: .center, andFont: UIFont.preferredFont(forTextStyle: .title3), andColor: .label)
let lastEditedLabel = ProjectTitleLabel(withTextAlignment: .center, andFont: UIFont.preferredFont(forTextStyle: .caption1), andColor: .secondaryLabel)
let imageView = ProjectImageView(frame: .zero)
var stackView = UIStackView()
var backgroundMaskedView = GradientView()
//MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
self.layer.cornerRadius = 35
let seperator = Separator(frame: .zero)
stackView = UIStackView(arrangedSubviews: [seperator, titleLabel, lastEditedLabel, imageView])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.distribution = .fillProportionally
stackView.spacing = 5
stackView.setCustomSpacing(10, after: lastEditedLabel)
stackView.insertSubview(backgroundMaskedView, at: 0)
contentView.addSubview(stackView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
//MARK: - Layout
override func layoutSubviews() {
super.layoutSubviews()
NSLayoutConstraint.activate([
titleLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: 20),
stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
stackView.topAnchor.constraint(equalTo: contentView.topAnchor),
stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
backgroundMaskedView.translatesAutoresizingMaskIntoConstraints = false
backgroundMaskedView.pinToEdges(of: stackView)
}
//MARK: - Configure
func configure(with project: ProjectsController.Project) {
titleLabel.text = project.title
lastEditedLabel.text = project.lastEdited.customMediumToString
imageView.image = Bundle.getProjectImage(project: project)
}
}
And the GradientView:
//
// GradientView.swift
//
import UIKit
class GradientView: UIView {
var topColor: UIColor = UIColor.tertiarySystemBackground
var bottomColor: UIColor = UIColor.systemPurple
override class var layerClass: AnyClass {
return CAGradientLayer.self
}
override func layoutSubviews() {
(layer as! CAGradientLayer).colors = [topColor.cgColor, bottomColor.cgColor]
(layer as! CAGradientLayer).locations = [0.0, 0.40]
}
}