UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

SOLVED: Issues with gradient layer in custom collectionview cell

Forums > iOS

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 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. Is it a reuse issue? Been at this for two weeks. Any help greatly appreciated as it's the only thing holding my app back from the app store!

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

3      

layoutSubviews is called many times during the lifetime of the view and should not be used to create constraints. At max you should use it to disable/enable existing constraints or set property like cornerRadius, if you need to know bounds (for example you are creating a circle)..

Try moving this code to the init and see if it helps.

3      

Thanks for your reply! I had the understanding that constraints better be set in layoutsubviews() ?!? I'll try to move all setup code to the init, but the problem is I need to have the cells (itemsize in flow layout) resize on traitcollection change as my app is universal AND split/multiscreen compatible ...

I'll be back :)

UPDATE I've put all code in the init, but then the masked layer isn't rendered at all. As soon as I put


        super.layoutSubviews()

        gradientMaskLayer.frame = backgroundMaskedView.bounds

    }

the layer is shown, albeit visibly late ... Seems in the init the bounds are not yet known?

UPDATE 2: yes in the init all bounds are still nil. Therefor I have to do that in layoutsubviews(). That's why I have it there. Also, the problem still perseveres when all is in the init(). Any other suggestions ?

3      

Well but the rest of the code was cleared up...

One thing I dont understand is the usage of CAGradientLayer. I am always using dedicated UIView as the gradient and I never had issue with AutoLayout constraints..

Here is a template:


@IBDesignable class GradientView: UIView {
    @IBInspectable var topColor: UIColor = UIColor.white
    @IBInspectable var bottomColor: UIColor = UIColor.black

    override class var layerClass: AnyClass {
        return CAGradientLayer.self
    }

    override func layoutSubviews() {
        (layer as! CAGradientLayer).colors = [topColor.cgColor, bottomColor.cgColor]
    }
}

4      

Your example also uses a CAGradientLayer ?? And my app uses no storyboards ...

3      

Yes, but the gradient layer is the only layer of the GradientView and therefore you don't have to manage its size.

The IB stuff can be safely deleted.

3      

I know, but my cells, ie my view, should resize with traitcollection changes....

3      

Filip! You're the man! Works now! So glad ... IOU!

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

3      

Nice! Glad it worked!

4      

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.