Swift - UIStackView - hide if height of all items is below threshold - uibutton

I have UIStackView in vertical mode filled with UIButtons. I have dynamic screen resize and if all buttons in stack view have height under some threshold, I want to hide them. How to achive this automatically?
I have tried to extend UIButton and add:
override func layoutSubviews() {
super.layoutSubviews()
self.isHidden = (self.frame.height < 20)
}
which works, but once the button is hidden it will never re-appear and layoutSubviews is never called back (even if the height should be again larger).

It's not clear what all you are doing, or why you say it would be problematic to set the buttons' .alpha property, but here are two approaches, both using a UIStackView subclass and handling the show/hide in layoutSubviews().
1: calculate what the button heights will be and set .isHidden property:
class MyStackView: UIStackView {
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
axis = .vertical
distribution = .fillEqually
spacing = 8
}
override func layoutSubviews() {
super.layoutSubviews()
// approach 1
// setting .isHidden
let numViews = arrangedSubviews.count
let numSpaces = numViews - 1
let h = (bounds.height - (spacing * CGFloat(numSpaces))) / CGFloat(numViews)
let bHide = h < 20
arrangedSubviews.forEach { v in
v.isHidden = bHide
}
}
}
set .isHidden property based on what the button heights are (much simpler):
class MyStackView: UIStackView {
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
axis = .vertical
distribution = .fillEqually
spacing = 8
}
override func layoutSubviews() {
super.layoutSubviews()
// approach 2
// setting .alpha
arrangedSubviews.forEach { v in
v.alpha = v.frame.height < 20 ? 0.0 : 1.0
}
}
}
And here's a sample controller to see it in use. Tapping anywhere will toggle the height of the stack view between 300 and 100 (buttons will have less-than 20-pts height at 100):
class ConditionalStackViewController: UIViewController {
let stackView: MyStackView = {
let v = MyStackView()
// so we can see the stack view frame
v.backgroundColor = .systemYellow
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
var stackHeight: NSLayoutConstraint!
override func viewDidLoad() {
super.viewDidLoad()
for i in 1...6 {
let b = UIButton()
b.setTitle("Button \(i)", for: [])
b.setTitleColor(.white, for: .normal)
b.setTitleColor(.lightGray, for: .highlighted)
b.backgroundColor = .red
stackView.addArrangedSubview(b)
}
view.addSubview(stackView)
let g = view.safeAreaLayoutGuide
stackHeight = stackView.heightAnchor.constraint(equalToConstant: 300.0)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
stackView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
stackView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
stackHeight,
])
let t = UITapGestureRecognizer(target: self, action: #selector(gotTap(_:)))
view.addGestureRecognizer(t)
}
#objc func gotTap(_ g: UITapGestureRecognizer) -> Void {
stackHeight.constant = stackHeight.constant == 300 ? 100 : 300
}
}

Related

Make NSCollectionLayoutItem heights as tall as the NSCollectionLayoutGroup when using estimated heights in a UICollectionViewLayout grid

I have a UICollectionViewLayout grid with three columns. Each item in the column has a cell full of text. I would like all the columns to be the same height as the tallest item in the group. Using UICollectionViewCompositionalLayout I'm having a hard time getting the desired results.
Any ideas on how to achieve this? All the code is below. The createLayout function is the most relevant. Please note I am using LoremSwiftum to generate the random text.
Current behavior
Expected behavior:
I was able to mock this expected behavior by changing the NSCollectionLayoutSize to be an absolute value of 500. This is not what I want. Each cell should estimate its size, and use the largest value of the group.
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(500))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(500))
Entire Code:
import UIKit
import LoremSwiftum
class ViewController: UIViewController {
enum Section {
case main
}
var dataSource: UICollectionViewDiffableDataSource<Section, Int>! = nil
var collectionView: UICollectionView! = nil
let sectionBackgroundDecorationElementKind = "section-background-element-kind"
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.title = "Two-Column Grid"
configureHierarchy()
configureDataSource()
}
/// - Tag: TwoColumn
func createLayout() -> UICollectionViewLayout {
let spacing = CGFloat(10)
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(44))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(44))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 3)
group.interItemSpacing = .fixed(spacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = spacing
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)
let layout = UICollectionViewCompositionalLayout(section: section)
return layout
}
func configureHierarchy() {
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.backgroundColor = .systemBackground
view.addSubview(collectionView)
}
func configureDataSource() {
let cellRegistration = UICollectionView.CellRegistration<TextCell, Int> { (cell, indexPath, identifier) in
// Populate the cell with our item description.
cell.label.text = Lorem.sentences(Bool.random() ? 1 : 3)
}
dataSource = UICollectionViewDiffableDataSource<Section, Int>(collectionView: collectionView) {
(collectionView: UICollectionView, indexPath: IndexPath, identifier: Int) -> UICollectionViewCell? in
// Return the cell.
return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: identifier)
}
// initial data
var snapshot = NSDiffableDataSourceSnapshot<Section, Int>()
snapshot.appendSections([.main])
snapshot.appendItems(Array(0..<94))
dataSource.apply(snapshot, animatingDifferences: false)
}
}
class TextCell: UICollectionViewCell {
let label = UILabel()
let bottomLabel = UILabel()
let container = UIView()
static let reuseIdentifier = "text-cell-reuse-identifier"
override init(frame: CGRect) {
super.init(frame: frame)
configure()
}
required init?(coder: NSCoder) {
fatalError("not implemented")
}
func configure() {
label.numberOfLines = 0
label.adjustsFontForContentSizeCategory = true
label.font = UIFont.preferredFont(forTextStyle: .title1)
label.backgroundColor = .systemPurple
label.textColor = .white
bottomLabel.numberOfLines = 0
bottomLabel.adjustsFontForContentSizeCategory = true
bottomLabel.font = UIFont.preferredFont(forTextStyle: .title1)
bottomLabel.text = "Bottom of cell"
bottomLabel.backgroundColor = .systemRed
bottomLabel.textColor = .white
let stack = UIStackView(arrangedSubviews: [label, bottomLabel])
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .vertical
stack.distribution = .equalSpacing
stack.spacing = 20
contentView.addSubview(stack)
backgroundColor = .systemBlue.withAlphaComponent(0.75)
let inset = CGFloat(10)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: inset),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -inset),
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: inset),
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -inset),
])
}
}
I ended up having to create a UICollectionViewCompositionalLayout subclass, but the solution works really well.
Using the new subclass, keep a weak reference to the layout in your cell so you can use it in preferredLayoutAttributesFitting.
class EqualHeightsUICollectionViewCompositionalLayout: UICollectionViewCompositionalLayout {
private var heights = [Int: [IndexPath: CGFloat]]()
private var largests = [Int: CGFloat]()
private let columns: Int
init(section: NSCollectionLayoutSection, columns: Int) {
self.columns = columns
super.init(section: section)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let attributes = super.layoutAttributesForElements(in: rect)
if #unavailable(iOS 15) {
if let attributes = attributes {
for attribute in attributes {
updateLayoutAttributesHeight(layoutAttributes: attribute)
}
}
}
return attributes
}
override func invalidateLayout() {
super.invalidateLayout()
heights.removeAll(keepingCapacity: true)
largests.removeAll(keepingCapacity: true)
}
func updateLayoutAttributesHeight(layoutAttributes: UICollectionViewLayoutAttributes) {
let height = layoutAttributes.frame.height
let indexPath = layoutAttributes.indexPath
let row = indexPath.item / columns
heights[row]?[indexPath] = height
largests[row] = max(largests[row] ?? 0, height)
let size = CGSize(width: layoutAttributes.frame.width,
height: largests[row] ?? 0)
layoutAttributes.frame = .init(origin: layoutAttributes.frame.origin, size: size)
}
}
class TextCell: UICollectionViewCell {
let label = UILabel()
weak var layout: EqualHeightsUICollectionViewCompositionalLayout?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .systemBlue.withAlphaComponent(0.75)
configure()
}
required init?(coder: NSCoder) {
fatalError("not implemented")
}
override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
let attribute = super.preferredLayoutAttributesFitting(layoutAttributes)
layout?.updateLayoutAttributesHeight(layoutAttributes: attribute)
return attribute
}
private func configure() {
let bottomLabel = UILabel()
label.numberOfLines = 0
label.font = UIFont.preferredFont(forTextStyle: .title1)
label.backgroundColor = .systemPurple
label.textColor = .white
label.setContentHuggingPriority(.defaultHigh, for: .vertical)
bottomLabel.numberOfLines = 0
bottomLabel.font = UIFont.preferredFont(forTextStyle: .title1)
bottomLabel.text = "Bottom of cell"
bottomLabel.backgroundColor = .systemRed
bottomLabel.textColor = .white
bottomLabel.setContentHuggingPriority(.defaultLow, for: .vertical)
let stackView = UIStackView(arrangedSubviews: [label, bottomLabel])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.spacing = 20
contentView.addSubview(stackView)
let padding: CGFloat = 15
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding),
stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -padding),
stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding),
stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding),
])
}
}

Why is my UICollectionView not loading cells?

I have a view controller in my app that plays user videos and you can scroll to the right to go to the next video. I have a cell set up that loads the video from the URL in firebase storage and loads the other data. Here is the cell code:
import UIKit
import AVFoundation
protocol ClipsCollectionViewCellDelegate: AnyObject {
func didTapProfile(with model: VideoModel)
func didTapShare(with model: VideoModel)
func didTapNewClip(with model: VideoModel)
}
class ClipsCollectionViewCell: UICollectionViewCell {
static let identifier = "ClipsCollectionViewCell"
// Labels
private let usernameLabel: UILabel = {
let label = UILabel()
label.textAlignment = .center
label.textColor = UIColor.systemPink.withAlphaComponent(0.5)
label.backgroundColor = UIColor.systemPink.withAlphaComponent(0.1)
label.clipsToBounds = true
label.layer.cornerRadius = 8
return label
}()
// Buttons
private let profileButton: UIButton = {
let button = UIButton()
button.setBackgroundImage(UIImage(systemName: "person.circle"), for: .normal)
button.tintColor = .white
button.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.1)
button.clipsToBounds = true
button.layer.cornerRadius = 32
button.isUserInteractionEnabled = true
return button
}()
private let shareButton: UIButton = {
let button = UIButton()
button.setBackgroundImage(UIImage(systemName: "square.and.arrow.down"), for: .normal)
button.tintColor = .white
button.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.1)
button.clipsToBounds = true
button.layer.cornerRadius = 4
button.isUserInteractionEnabled = true
return button
}()
private let newClipButton: UIButton = {
let button = UIButton()
button.setBackgroundImage(UIImage(systemName: "plus"), for: .normal)
button.tintColor = .systemOrange
button.backgroundColor = UIColor.systemOrange.withAlphaComponent(0.1)
button.clipsToBounds = true
button.layer.cornerRadius = 25
button.isUserInteractionEnabled = true
return button
}()
private let videoContainer = UIView()
// Delegate
weak var delegate: ClipsCollectionViewCellDelegate?
// Subviews
var player: AVPlayer?
private var model: VideoModel?
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = .black
contentView.clipsToBounds = true
addSubviews()
}
private func addSubviews() {
contentView.addSubview(videoContainer)
contentView.addSubview(usernameLabel)
contentView.addSubview(profileButton)
contentView.addSubview(shareButton)
contentView.addSubview(newClipButton)
// Add actions
profileButton.addTarget(self, action: #selector(didTapProfileButton), for: .touchUpInside)
shareButton.addTarget(self, action: #selector(didTapShareButton), for: .touchUpInside)
newClipButton.addTarget(self, action: #selector(didTapNewClipButton), for: .touchUpInside)
videoContainer.clipsToBounds = true
contentView.sendSubviewToBack(videoContainer)
}
#objc private func didTapProfileButton() {
guard let model = model else {
return
}
delegate?.didTapProfile(with: model)
}
#objc private func didTapShareButton() {
guard let model = model else {
return
}
delegate?.didTapShare(with: model)
}
#objc private func didTapNewClipButton() {
guard let model = model else {
return
}
delegate?.didTapNewClip(with: model)
}
override func layoutSubviews() {
super.layoutSubviews()
videoContainer.frame = contentView.bounds
let size = contentView.frame.size.width/7
let width = contentView.frame.size.width
let height = contentView.frame.size.height
// Labels
usernameLabel.frame = CGRect(x: (width-(size*3))/2, y: height-880-(size/2), width: size*3, height: size)
// Buttons
profileButton.frame = CGRect(x: width-(size*7), y: height-850-size, width: size, height: size)
shareButton.frame = CGRect(x: width-size, y: height-850-size, width: size, height: size)
newClipButton.frame = CGRect(x: width-size-10, y: height-175-size, width: size/1.25, height: size/1.25)
}
override func prepareForReuse() {
super.prepareForReuse()
usernameLabel.text = nil
player?.pause()
player?.seek(to: CMTime.zero)
}
public func configure(with model: VideoModel) {
self.model = model
configureVideo()
// Labels
usernameLabel.text = model.username
}
private func configureVideo() {
guard let model = model else {
return
}
guard let url = URL(string: model.videoFileURL) else { return }
player = AVPlayer(url: url)
let playerView = AVPlayerLayer()
playerView.player = player
playerView.frame = contentView.bounds
playerView.videoGravity = .resizeAspect
videoContainer.layer.addSublayer(playerView)
player?.volume = 0
player?.play()
player?.actionAtItemEnd = .none
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Here is the view controller code:
import UIKit
struct VideoModel {
let username: String
let videoFileURL: String
}
class BetaClipsViewController: UIViewController, UICollectionViewDelegate {
private var collectionView: UICollectionView?
private var data = [VideoModel]()
/// Notification observer
private var observer: NSObjectProtocol?
/// All post models
private var allClips: [(clip: Clip, owner: String)] = []
private var viewModels = [[ClipFeedCellType]]()
override func viewDidLoad() {
super.viewDidLoad()
title = ""
// for _ in 0..<10 {
// let model = VideoModel(username: "#CJMJM",
// videoFileURL: "https://firebasestorage.googleapis.com:443/v0/b/globe-e8b7f.appspot.com/o/clipvideos%2F1637024382.mp4?alt=media&token=c12d0481-f834-4a17-8eee-30595bdf0e8b")
// data.append(model)
// }
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.itemSize = CGSize(width: view.frame.size.width,
height: view.frame.size.height)
layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
layout.minimumInteritemSpacing = 0
layout.minimumLineSpacing = 0
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView?.register(ClipsCollectionViewCell.self,
forCellWithReuseIdentifier: ClipsCollectionViewCell.identifier)
collectionView?.isPagingEnabled = true
collectionView?.delegate = self
collectionView?.dataSource = self
view.addSubview(collectionView!)
fetchClips()
observer = NotificationCenter.default.addObserver(
forName: .didPostNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.viewModels.removeAll()
self?.fetchClips()
}
self.collectionView?.reloadData()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
collectionView?.frame = view.bounds
}
private func fetchClips() {
guard let username = UserDefaults.standard.string(forKey: "username") else {
return
}
let userGroup = DispatchGroup()
userGroup.enter()
var allClips: [(clip: Clip, owner: String)] = []
DatabaseManager.shared.users(for: username) { usernames in
defer {
userGroup.leave()
}
let users = usernames + [username]
for current in users {
userGroup.enter()
DatabaseManager.shared.clips(for: current) { result in
DispatchQueue.main.async {
defer {
userGroup.leave()
}
switch result {
case .success(let clips):
allClips.append(contentsOf: clips.compactMap({
(clip: $0, owner: current)
}))
case .failure:
break
}
}
}
}
}
userGroup.notify(queue: .main) {
let group = DispatchGroup()
self.allClips = allClips
allClips.forEach { model in
group.enter()
self.createViewModel(
model: model.clip,
username: model.owner,
completion: { success in
defer {
group.leave()
}
if !success {
print("failed to create VM")
}
}
)
}
group.notify(queue: .main) {
self.collectionView?.reloadData()
}
}
}
}
extension BetaClipsViewController: UICollectionViewDataSource {
func numberOfSections(in collectionView: UICollectionView) -> Int {
return viewModels.count
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return viewModels[section].count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cellType = viewModels[indexPath.section][indexPath.row]
switch cellType {
case .clip(let viewModel):
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ClipsCollectionViewCell.identifier,
for: indexPath)
as? ClipsCollectionViewCell else {
fatalError()
}
cell.delegate = self
cell.configure(with: viewModel)
return cell
}
}
}
extension BetaClipsViewController: ClipsCollectionViewCellDelegate {
func didTapProfile(with model: VideoModel) {
print("profile tapped")
let owner = model.username
DatabaseManager.shared.findUser(username: owner) { [weak self] user in
DispatchQueue.main.async {
guard let user = user else {
return
}
let vc = ProfileViewController(user: user)
self?.navigationController?.pushViewController(vc, animated: true)
}
}
}
func didTapShare(with model: VideoModel) {
print("profile share")
}
func didTapNewClip(with model: VideoModel) {
let vc = RecordViewController()
navigationController?.pushViewController(vc, animated: true)
}
}
extension BetaClipsViewController {
func createViewModel(
model: Clip,
username: String,
completion: #escaping (Bool) -> Void
) {
StorageManager.shared.profilePictureURL(for: username) { [weak self] profilePictureURL in
guard let clipURL = URL(string: model.clipUrlString),
let profilePhotoUrl = profilePictureURL else {
return
}
let clipData: [ClipFeedCellType] = [
.clip(viewModel: VideoModel(username: username,
videoFileURL: model.clipUrlString))
]
self?.viewModels.append(clipData)
completion(true)
}
}
}
I think everything is set up properly in the code so why do no cells show in the collectionView? The view controller shows up as completely blank besides the background color.

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

Mkmap iOS11 clusters doesn't split up after max zoom, how to set it up?

First, my code is perfectly running.
I have well set up and mapView.register my annotation markers and cluster.
When I zoom out the annotations fusion as expected in my cluster views,
when I zoom in, same good result, except at a certain point. When too many annotations are too close from each other, the cluster view doesn't split up into my two annotation views anymore.
So I search a way to be able to setup this "zoom level" that will makes appear my two annotations even if there are really close from each other.
Here are my cluster views with a high zoom on the map:
Here if I zoom at the maximum:
Well, one of the cluster views split into two, but doesn't reveal the 4 annotations.
I also try to setup the displayPriority to be higher for my two annotations, than the cluster view, but the result is still the same.
Any ideas ?
You will need to keep track of the zoom level of the map, and reload your annotations when you cross a zoom level that you specify.
private let maxZoomLevel = 9
private var previousZoomLevel: Int?
private var currentZoomLevel: Int? {
willSet {
self.previousZoomLevel = self.currentZoomLevel
}
didSet {
// if we have crossed the max zoom level, request a refresh
// so that all annotations are redrawn with clustering enabled/disabled
guard let currentZoomLevel = self.currentZoomLevel else { return }
guard let previousZoomLevel = self.previousZoomLevel else { return }
var refreshRequired = false
if currentZoomLevel > self.maxZoomLevel && previousZoomLevel <= self.maxZoomLevel {
refreshRequired = true
}
if currentZoomLevel <= self.maxZoomLevel && previousZoomLevel > self.maxZoomLevel {
refreshRequired = true
}
if refreshRequired {
// remove the annotations and re-add them, eg
let annotations = self.mapView.annotations
self.mapView.removeAnnotations(annotations)
self.mapView.addAnnotations(annotations)
}
}
}
private var shouldCluster: Bool {
if let zoomLevel = self.currentZoomLevel, zoomLevel <= maxZoomLevel {
return false
}
return true
}
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
// https://stackoverflow.com/a/40616239/883413
let zoomWidth = mapView.visibleMapRect.size.width
let zoomLevel = Int(log2(zoomWidth))
self.currentZoomLevel = zoomLevel
}
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
// for me, annotation reuse doesn't work with clustering
let annotationView = CustomAnnotationView(annotation: annotation)
if self.shouldCluster {
annotationView.clusteringIdentifier = "custom-id"
} else {
annotationView.clusteringIdentifier = nil
}
return annotationView
}
In my case, ! EVERY TIME ! I didn't update the clusteringIdentifier
in "func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation)"
When the MKAnnotationView is reused by the mapView.dequeueReusableAnnotationView(withIdentifier: "identifier", for: annotation), the clusteringIdentifier will be nil. (reset)
That's the reason why the clusters doesn't work.
AnnotationView.swift
import MapKit
// MARK: - Define
struct AnnotationViewInfo {
static let identifier = "AnnotationView"
}
final class AnnotationView: MKAnnotationView {
// MARK: - Initializer
override init(annotation: MKAnnotation!, reuseIdentifier: String!) {
super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
setView()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setView()
}
// MARK: - Value
// MARK: Public
override var annotation: MKAnnotation? {
willSet { update(annotation: newValue) }
}
// MARK: - Function
// MARK: Private
private func setView() {
if #available(iOS 11.0, *) {
collisionMode = .rectangle
clusteringIdentifier = AnnotationViewInfo.identifier
}
canShowCallout = true
image = #imageLiteral(resourceName: "pin01").resizedImage(size: CGSize(width: #imageLiteral(resourceName: "pin01").size.width/4.0, height: #imageLiteral(resourceName: "pin01").size.height/4.0), scale: 1.0)
}
private func update(annotation: MKAnnotation?) {
if #available(iOS 11.0, *) {
clusteringIdentifier = AnnotationViewInfo.identifier
}
// TODO: Update the annotationView
}
}
MKMapViewDelegate
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
if #available(iOS 11.0, *) {
switch annotation {
case is PointAnnotation: return mapView.dequeueReusableAnnotationView(withIdentifier: AnnotationView1Info.identifier, for: annotation)
case is MKClusterAnnotation: return mapView.dequeueReusableAnnotationView(withIdentifier: ClusterAnnotationViewInfo.identifier, for: annotation)
case is MKUserLocation: return nil
default: return nil
}
} else {
return nil
}
}
Key Point (You must update the "clusteringIdentifier" every time.)
private func update(annotation: MKAnnotation?) {
if #available(iOS 11.0, *) {
clusteringIdentifier = AnnotationViewInfo.identifier
}
// TODO: Update the annotationView
}
}
Sample Project Here

tvos: UITextView focus appearance like movies App

I am building a tvos app and i want the UITextView to behave similarly like in tvos Movies app. I am specially interested in the focused appearence. Please have a look ate these two pictures.
Currently i am just adding background color to the textview when it is focused but how i can achieve this focused appearance in the attached images. here is my small code
override func didUpdateFocusInContext(context: UIFocusUpdateContext, withAnimationCoordinator coordinator: UIFocusAnimationCoordinator) {
super.didUpdateFocusInContext(context, withAnimationCoordinator: coordinator)
if context.previouslyFocusedView == lessonDescriptionTxt {
coordinator.addCoordinatedAnimations({ () -> Void in
self.lessonDescriptionTxt.layer.backgroundColor = UIColor.clearColor().CGColor
}, completion: nil)
}
if context.nextFocusedView == lessonDescriptionTxt {
coordinator.addCoordinatedAnimations({ () -> Void in
self.lessonDescriptionTxt.layer.backgroundColor = UIColor.blackColor().colorWithAlphaComponent(0.2).CGColor
}, completion: nil)
}
}
Also if someone can suggest how i can achieve this MORE feature in the textView when there is more text. I also read this question Make UILabel focusable and tappable (tvOS) but that does not do the job for me.
The "MORE" text appears only when the description can't fit in the space that's available. You should be able to measure the amount of space needed by text by using UIKit's -[NSAttributedString boundingRectWithSize:options:context:] method.
Implementing custom FocusableTextView class worked for me:
class FocusableTextView: UITextView {
let suffixWithMore = " ... MORE"
weak var tapDelegate: FocusableTextViewDelegate?
var currentText = ""
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
isScrollEnabled = true // must be set to true for 'stringThatFitsOnScreen' function to work
isUserInteractionEnabled = true
isSelectable = true
textAlignment = .justified
self.font = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body)
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
tap.allowedPressTypes = [NSNumber(value: UIPress.PressType.select.rawValue)]
self.addGestureRecognizer(tap)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// use setText(_:) function instead of assigning directly to text variable
func setText(_ txt: String) {
currentText = txt
let stringThatFits = stringThatFitsOnScreen(originalString: txt) ?? txt
if txt <= stringThatFits {
self.text = txt
} else {
let newString = makeStringWithMORESuffix(from: stringThatFits)
self.text = newString
}
}
func makeStringWithMORESuffix(from txt: String) -> String {
let newNSRange = NSMakeRange(0, txt.count - suffixWithMore.count)
let stringRange = Range(newNSRange,in: txt)!
let subString = String(txt[stringRange])
return subString + suffixWithMore
}
func stringThatFitsOnScreen(originalString: String) -> String? {
// the visible rect area the text will fit into
let userWidth = self.bounds.size.width - self.textContainerInset.right - self.textContainerInset.left
let userHeight = self.bounds.size.height - self.textContainerInset.top - self.textContainerInset.bottom
let rect = CGRect(x: 0, y: 0, width: userWidth, height: userHeight)
// we need a new UITextView object to calculate the glyphRange. This is in addition to
// the UITextView that actually shows the text (probably a IBOutlet)
let tempTextView = UITextView(frame: self.bounds)
tempTextView.font = self.font
tempTextView.text = originalString
// get the layout manager and use it to layout the text
let layoutManager = tempTextView.layoutManager
layoutManager.ensureLayout(for: tempTextView.textContainer)
// get the range of text that fits in visible rect
let rangeThatFits = layoutManager.glyphRange(forBoundingRect: rect, in: tempTextView.textContainer)
// convert from NSRange to Range
guard let stringRange = Range(rangeThatFits, in: originalString) else {
return nil
}
// return the text that fits
let subString = originalString[stringRange]
return String(subString)
}
#objc func tapped(_ gesture: UITapGestureRecognizer) {
print("user selected TextView!")
tapDelegate?.userSelectedText(currentText)
}
override func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
if (context.nextFocusedView == self) {
backgroundColor = .white
textColor = .black
}
else {
backgroundColor = .clear
textColor = .white
}
}
}
protocol FocusableTextViewDelegate: AnyObject {
func userSelectedText(_ txt: String)
}
When user taps the text View, you can present Alert with full text likewise:
extension YourViewController: FocusableTextViewDelegate{
func userSelectedText(_ txt: String) {
let alert = UIAlertController(title: "", message: txt, preferredStyle: .alert)
let action = UIAlertAction( title: nil, style: .cancel) {_ in }
alert.addAction(action)
self.present(alert, animated: true)
}
}
Usage:
create FocusableTextView programmatically:
assign constraints programmatically to textView (or use frame)
set text to FocusableTextView with setText(_:) method
assing your UIViewController to be the tapDelegate
override func viewDidLoad() {
super.viewDidLoad()
let textView = FocusableTextView(frame: CGRect(x: 0, y: 0,
width: 500,
height: 300),
textContainer: nil)
textView.setText("Your long text..")
textView.tapDelegate = self
}