uicollectionviewuikituicollectionviewcellswift5flowlayout

CollectionView FlowLayout custom cell rendering issues


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:) enter image description here) 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
    }
}

Solution

  • 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]
        }
    }