Subclassing UICollectionViewFlowLayout - autosizing cell frame calculation issue in iOS 12 - ios

I'm using the following to left align cells in a UICollectionView…
class LeftAlignedFlowLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let attributes = super.layoutAttributesForElements(in: rect) else { return nil }
for attributes in attributes where attributes.representedElementCategory == .cell {
attributes.frame = layoutAttributesForItem(at: attributes.indexPath).frame
}
return attributes
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes {
let currentItemAttributes = super.layoutAttributesForItem(at: indexPath)!
let layoutWidth = collectionView!.frame.width - sectionInset.left - sectionInset.right
// Just left align the first item in a section
if indexPath.item == 0 {
currentItemAttributes.frame.origin.x = sectionInset.left
return currentItemAttributes
}
let previousIndexPath = IndexPath(item: indexPath.item - 1, section: indexPath.section)
let previousItemFrame = layoutAttributesForItem(at: previousIndexPath).frame
let currentItemFrame = currentItemAttributes.frame
let wholeRowFrameForCurrentItem = CGRect(x: sectionInset.left, y: currentItemFrame.minY, width: layoutWidth, height: currentItemFrame.height)
// Left align the first item on a row
if !previousItemFrame.intersects(wholeRowFrameForCurrentItem) {
currentItemAttributes.frame.origin.x = sectionInset.left
return currentItemAttributes
}
currentItemAttributes.frame.origin.x = previousItemFrame.maxX + minimumInteritemSpacing
return currentItemAttributes, it's
}
}
Building for iOS 11 gives…
But for iOS 12 gives…
In iOS 11, layoutAttributesForElements(in) is called 3 times - the final time with the correct frame sizes. In iOS 12, it's only called twice, with frames being the estimated size.
Per this answer, https://stackoverflow.com/a/52148520/123632 I've tried invalidating the flow layout using the UICollectionView subclass below (and "brute-forcing" in viewDidAppear of the containing view controller), but the result is unchanged.
class LeftAlignedCollectionView: UICollectionView {
private var shouldInvalidateLayout = false
override func layoutSubviews() {
super.layoutSubviews()
if shouldInvalidateLayout {
collectionViewLayout.invalidateLayout()
shouldInvalidateLayout = false
}
}
override func reloadData() {
shouldInvalidateLayout = true
super.reloadData()
}
}

This is a known issue in iOS 12 (see UIKit section here: https://developer.apple.com/documentation/ios_release_notes/ios_12_release_notes)
The workaround is to either avoid calling setNeedsUpdateConstraints() or call updateConstraintsIfNeeded() before calling systemLayoutSizeFitting(_:).

I found a solution. Constraining the contentView to the cell fixed it…
override func awakeFromNib() {
contentView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([contentView.leftAnchor.constraint(equalTo: leftAnchor),
contentView.rightAnchor.constraint(equalTo: rightAnchor),
contentView.topAnchor.constraint(equalTo: topAnchor),
contentView.bottomAnchor.constraint(equalTo: bottomAnchor)])
}

I added this in the MyCustomCollectionViewCell file and it worked fine
override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
return contentView.systemLayoutSizeFitting(CGSize(width: targetSize.width, height: 1))
}

Related

UICollectionView Cell Anchor Point not working

I am trying to achieve a collection view where the cells are aligned at the bottom with a paging effect where the "selected" cell is bigger than the rest. Like this:
As of now, I am able to get the effect to work but the cells are aligned in the middle instead of at the bottom:
I have tried setting the anchorPoint property of the cell to pin the cells at (0, 1) in apply(_ layoutAttributes: UICollectionViewLayoutAttributes), but this causes the cells to move and as as a result they appear cut off. This ends up looking like this:
How do I pin these collection view cells at the bottom left corner, also respecting the CGAffine scale effect that occurs during paging?
Here is my code:
Custom UICollectionViewFlowLayout:
import Foundation
import UIKit
/// The layout used in the cover flow.
class CoverFlowLayout: UICollectionViewFlowLayout {
let activeDistance: CGFloat = 25
let zoomFactor: CGFloat = (CoverFlowCell.selectedSize / CoverFlowCell.unselectedSize) - 1
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = collectionView else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity) }
let itemSpace = itemSize.width + minimumInteritemSpacing
var currentItemIdx = round(collectionView.contentOffset.x / itemSpace)
let vX = velocity.x
if vX > 0 {
currentItemIdx += 1
} else if vX < 0 {
currentItemIdx -= 1
}
let nearestPageOffset = currentItemIdx * itemSpace
return CGPoint(x: nearestPageOffset, y: 0)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let collectionView = collectionView else { return nil }
let rectAttributes = super.layoutAttributesForElements(in: rect)!.map { $0.copy() as! UICollectionViewLayoutAttributes }
let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.frame.size)
// Make the cells be zoomed when they reach the center of the screen
for attributes in rectAttributes where attributes.frame.intersects(visibleRect) {
let distance = (visibleRect.minX + 20) - attributes.frame.minX
let normalizedDistance = distance / activeDistance
if distance.magnitude < activeDistance {
let zoom = 1 + zoomFactor * (1 - normalizedDistance.magnitude)
attributes.transform = CGAffineTransform(scaleX: zoom, y: zoom)
}
}
return rectAttributes
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
// Invalidate layout so that every cell get a chance to be zoomed when it reaches the center of the screen
return true
}
override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
let context = super.invalidationContext(forBoundsChange: newBounds) as! UICollectionViewFlowLayoutInvalidationContext
context.invalidateFlowLayoutDelegateMetrics = newBounds.size != collectionView?.bounds.size
return context
}
}
Custom UICollectionView Cell
import Foundation
import UIKit
class CoverFlowCell: UICollectionViewCell {
static let unselectedSize: CGFloat = 185; // The size of the cell when it is not selected in the carousel
static let selectedSize: CGFloat = 200;
private var albumArt: UIImageView = {
let art = UIImageView()
art.backgroundColor = UIColor(hexString: "#ECF0F1")
art.translatesAutoresizingMaskIntoConstraints = false
art.layer.cornerRadius = 2
return art
}()
/// Initializer
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
setupUIConstraints()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func awakeFromNib() {
super.awakeFromNib()
}
private func setupUI() {
contentView.addSubview(albumArt)
}
override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
super.apply(layoutAttributes)
//we must change the anchor point for propper cells positioning and scaling
self.layer.anchorPoint.x = 0
self.layer.anchorPoint.y = 1
}
private func setupUIConstraints() {
NSLayoutConstraint.activate([
self.albumArt.topAnchor.constraint(equalTo: topAnchor),
self.albumArt.bottomAnchor.constraint(equalTo: bottomAnchor),
self.albumArt.leftAnchor.constraint(equalTo: leftAnchor),
self.albumArt.rightAnchor.constraint(equalTo: rightAnchor)
])
}
}
I have tried referring to this thread:
Changing my CALayer's anchorPoint moves the view
But the solution provided did not help align the cells at the bottom.
Thanks
Add another transform to translate the y position, to slide it up after you scale it up:
let y = //set a negative number here, to slide up by that many points
transform = CGAffineTransform(translationX: 0, y: y)
I would also just apply the transform in the "did select item" method, rather than fuss with it in the layout attributes methods. Then when the cell is "deselected", you can just set the transform to .identity to reset it back to the normal layout.
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
//set transforms
}
func collectionView(_ UICollectionView, didDeselectItemAt: IndexPath) {
//set transform to .identity
}

How archive like this CollectionView stacked Cell for Overlapping Cell Like Wallet

I want exactly when tap particular cell it will show expand
let layout = UICollectionViewFlowLayout()
layout.minimumLineSpacing = -UIScreen.main.bounds.width/2.08
i want expand cell?
Friends any suggestion and needs helpful for us
Thanks
Usually we use UICollectionViewFlowLayout, once the item's size and spacing is given, the item's frame is settled.
To do
CollectionView stacked Cell for Overlapping Cell Like Wallet
You need to give the frame you want for every item in UICollectionView.
Finishing it by customizing UICollectionViewLayout, and use your own UICollectionViewLayout subclass.
The final effect is as the following image
The basic thing:
To do custom layout is easy:
override public func prepare() ,
to calculate the frames for every item,
And put the frame for item in its container , your custom UICollectionViewLayoutAttributes
override public func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? ,
Retrieves layout information for an item at the specified index path with a corresponding cell.
to assign your prepared custom UICollectionViewLayoutAttributes for item layout
override public func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? ,
Retrieves the layout attributes for all of the cells and views in the specified rectangle.
There is no supplementary views, and decoration views in this situation, just to handle items.
to assign your prepared custom UICollectionViewLayoutAttributes for UICollectionView layout
Key Point:
You have two kinds of layout state.
the initial state, and the unselected state is the same, when no card is selected.
The front card are partly hidden, and the last card is full shown.
The custom UICollectionViewLayoutAttributes, with isExpand to record if there is cell selected.
isExpand is used for an cell added UIPanGestureRecognizer
class CardLayoutAttributes: UICollectionViewLayoutAttributes {
var isExpand = false
override func copy(with zone: NSZone? = nil) -> Any {
let attribute = super.copy(with: zone) as! CardLayoutAttributes
attribute.isExpand = isExpand
return attribute
}
}
The frame calculation of state unselected is easy.
Set the frame of the first item,
then the second ...
y: titleHeight * CGFloat(index) , all OK
fileprivate func setNoSelect(attribute:CardLayoutAttributes) {
guard let collection = collectionView else {
return
}
let noneIdx = Int(collection.contentOffset.y/titleHeight)
if noneIdx < 0 {
return
}
attribute.isExpand = false
let index = attribute.zIndex
var currentFrame = CGRect(x: collection.frame.origin.x, y: titleHeight * CGFloat(index), width: cellSize.width, height: cellSize.height)
if index == noneIdx{
attribute.frame = CGRect(x: currentFrame.origin.x, y: collection.contentOffset.y, width: cellSize.width, height: cellSize.height)
}
else if index <= noneIdx, currentFrame.maxY > collection.contentOffset.y{
currentFrame.origin.y -= (currentFrame.maxY - collection.contentOffset.y )
attribute.frame = currentFrame
}
else {
attribute.frame = currentFrame
}
}
the selected state, to select an item, the item is expanded , others will make room for it.
The logic is to put the selected item in the middle, the y position matters, collection.contentOffset.y + offsetSelected,
The center item's frame is known , then calculate the two sides.
One side is items from (selectedIdx-1) to 0, calculate the frame of item.
The Other side is items from (selectedIdx+1) to the final index , also calculate item's frame.
fileprivate func calculate(for attributes: [CardLayoutAttributes], choose selectedIP: IndexPath) -> [CGRect]{
guard let collection = collectionView else {
return []
}
let noneIdx = Int(collection.contentOffset.y / titleHeight)
if noneIdx < 0 {
return []
}
let x = collection.frame.origin.x
var selectedIdx = 0
for attr in attributes{
if attr.indexPath == selectedIP{
break
}
selectedIdx += 1
}
var frames = [CGRect](repeating: .zero, count: attributes.count)
// Edit here
let offsetSelected: CGFloat = 100
let marginBottomSelected: CGFloat = 10
frames[selectedIdx] = CGRect(x: x, y: collection.contentOffset.y + offsetSelected, width: cellSize.width, height: cellSize.height)
if selectedIdx > 0{
for i in 0...(selectedIdx-1){
frames[selectedIdx - i - 1] = CGRect(x: x, y: frames[selectedIdx].origin.y - titleHeight * CGFloat(i + 1), width: cellSize.width, height: cellSize.height)
}
}
if selectedIdx < (attributes.count - 1){
for i in (selectedIdx + 1)...(attributes.count - 1){
frames[i] = CGRect(x: x, y: frames[selectedIdx].origin.y + marginBottomSelected + titleHeight * CGFloat(i - selectedIdx - 1) + cellSize.height, width: cellSize.width, height: cellSize.height)
}
}
return frames
}
When there is a item selected, you should refresh the custom layout.
To call invalidateLayout(),
Invalidates the current layout and triggers a layout update.
fileprivate var _selectPath: IndexPath? {
didSet {
self.collectionView!.isScrollEnabled = (_selectPath == nil)
}
}
public var selectPath: IndexPath? {
set {
_selectPath = (_selectPath == newValue) ? nil : newValue
self.collectionView?.performBatchUpdates({
self.invalidateLayout()
}, completion: nil)
} get {
return _selectPath
}
}
one more thing, the sample demo in github
Here is the overlapping the layout, minimumLineSpacing negative number
if click the card it should be expanded and scroll down and up card
should not disclose anymoew like wallet
class OverlappedCustomFlowLayout: UICollectionViewFlowLayout {
override func prepare() {
super.prepare()
// This allows us to make intersection and overlapping
// A negative number implies overlapping whereas positive implies space between the adjacent edges of two cells.
minimumLineSpacing = -100
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let layoutAttributes = super.layoutAttributesForElements(in: rect)
for currentLayoutAttributes: UICollectionViewLayoutAttributes in layoutAttributes! {
// zIndex - Specifies the item’s position on the z-axis.
// Unlike a layer's zPosition, changing zIndex allows us to change not only layer position,
// but tapping/UI interaction logic too as it moves the whole item.
currentLayoutAttributes.zIndex = currentLayoutAttributes.indexPath.row + 1
}
return layoutAttributes
}
}
you can try the following idea
The layout part:
var selectedIdx: Int?
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
let layoutAttributes = super.layoutAttributesForElements(in: rect)
var originY: CGFloat = 0
let defaultOffsetY: CGFloat = 80
if let idx = selectedIdx{
// expanded layout
for i in 0..<cnt{
// frame the attribute
if i == idx + 1{
// to expand is to make room for the rest items
originY += 400
}
else{
originY += defaultOffsetY
}
}
}
else{
// default layout
for i in 0..<cnt{
// frame the attribute
originY += defaultOffsetY
}
}
return layoutAttributes
}
trigger the expand action
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
// edit the state
layout.selectedIdx = indexPath.item
// trigger refreshing the collectionView's layout
collectionView.reloadData()
}
I have tried it's working fine, thanks to everyone
class CardLayout: UICollectionViewLayout {
var contentHeight: CGFloat = 0.0
var cachedAttributes = [UICollectionViewLayoutAttributes]()
var nextIndexPath: Int?
override init() {
super.init()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var collectionViewContentSize: CGSize {
var size = super.collectionViewContentSize
let collection = collectionView!
size.width = collection.bounds.size.width
if let _ = FlowLayoutAttributes.shared.cellIndex{
size.height = contentHeight+UIScreen.main.bounds.width/2+38
}else{
size.height = contentHeight
}
print("Contend",contentHeight)
return size
}
func reloadData(){
self.cachedAttributes = [UICollectionViewLayoutAttributes]()
}
override func prepare() {
cachedAttributes.removeAll()
guard let numberOfItems = collectionView?.numberOfItems(inSection: 0) else {
return
}
for index in 0..<numberOfItems {
let layout = UICollectionViewLayoutAttributes(forCellWith: IndexPath(row: index, section: 0))
layout.frame = frameFor(index: index)
if let indexExpand = FlowLayoutAttributes.shared.cellIndex, indexExpand == index {
self.nextIndexPath = index+1
contentHeight = CGFloat(CGFloat(numberOfItems)*getCardSize())+UIScreen.main.bounds.width/2+38*2
}else{
contentHeight = CGFloat(CGFloat(numberOfItems)*getCardSize())+UIScreen.main.bounds.width/2+38
}
layout.zIndex = index
layout.isHidden = false
cachedAttributes.append(layout)
}
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var layoutAttributes = [UICollectionViewLayoutAttributes]()
for attributes in cachedAttributes {
if attributes.frame.intersects(rect) {
layoutAttributes.append(cachedAttributes[attributes.indexPath.item])
}
}
return layoutAttributes
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return cachedAttributes[indexPath.item]
}
func frameFor(index: Int) -> CGRect {
var frame = CGRect(origin: CGPoint(x: CGFloat(8), y:0), size: CGSize(width: UIScreen.main.bounds.width - CGFloat(8 + 8), height: CGFloat(UIScreen.main.bounds.width/2+38)))
var frameOrigin = frame.origin
if let indexExpand = FlowLayoutAttributes.shared.cellIndex{
if index > 0 {
if indexExpand < index {
let spacesHeight = CGFloat((getCardSize() * CGFloat(index)))+UIScreen.main.bounds.width/2+38-getCardSize()/2
frameOrigin.y = spacesHeight
}else{
frameOrigin.y = CGFloat((getCardSize() * CGFloat(index)))
}
}
}else{
if index > 0 {
frameOrigin.y = CGFloat((getCardSize() * CGFloat(index)))
}
}
frame.origin = frameOrigin
return frame
}
func getCardSize()-> CGFloat{
if UIDevice().userInterfaceIdiom == .phone {
switch UIScreen.main.nativeBounds.height {
case 1136:
print("iPhone 5 or 5S or 5C")
return 45.25
case 1334:
print("iPhone 6/6S/7/8")
return 45.25
case 1920, 2208:
print("iPhone 6+/6S+/7+/8+")
return 46
case 2436:
print("iPhone X/XS/11 Pro")
return 45.25
case 2688:
print("iPhone XS Max/11 Pro Max")
return 46
case 1792:
print("iPhone XR/ 11 ")
return 46
case 2532:
print("iPhone 12/ iPhone 12 Pro")
return 45.50
case 2778:
print("iPhone 12 Pro Max")
return 46.2
default:
return 46.2
}
}else{
return CGFloat(46.2)
}
}
}

When Should I Recalculate A UITableView Footer's Size?

I would like to recalculate the height of a table view's footer based upon the table view's changing content size. When the table has zero rows the height of the footer will be at its maximum. As rows are added to the table the footer's height will be reduced until it reaches a minimum. What I am doing is using the footer to fill up the empty space that appears at the bottom of the table when there are zero or few rows. In addition to rows being added it is possible for the content size to change because the height (content) of an existing row has been changed.
Supposing that I have a view controller whose main view contains two subviews: a button and a table view. Clicking the button results in the data store being modified and the table's reloadData method being called. When/Where would I assign a new value to the table's tableFooterView.bounds.size.height?
I should also point out that I am using UITableViewAutomaticDimension. If, in the table's data source delegate method cellForRowAt, I print the cell heights I get:
Upper table cell height = 21.0
Upper table cell height = 21.0
Upper table cell height = 21.0
Upper table cell height = 21.0
Upper table cell height = 44.0
All 21 except for the last one, the new one. This must be due to the automatic dimensioning not yet having been applied.
Update:
I have tentatively arrived at the following solution (many thanks to all of the folks on this thread for the biggest part of the solution). I am tentative because the solution involves calling reloadData twice in order to deal with an issue with the contentSize. See this GitHub project for a demo of the contentSize issue.
class TableView: UITableView {
override func reloadData() {
execute() { super.reloadData() }
}
override func reloadRows(at indexPaths: [IndexPath], with animation: UITableView.RowAnimation) {
execute() { super.reloadRows(at: indexPaths, with: animation) }
}
private func execute(reload: #escaping () -> Void) {
CATransaction.begin()
CATransaction.setCompletionBlock() {
if self.adjustFooter() {
reload() // Cause the contentSize to update (see GitHub project)
self.layoutIfNeeded()
}
}
reload()
CATransaction.commit()
}
// Return true(false) if the footer was(was not) adjusted
func adjustFooter() -> Bool {
guard let currentFrame = tableFooterView?.frame else { return false }
let newHeight = calcFooterHeight()
let adjustmentNeeded = newHeight != currentFrame.height
if adjustmentNeeded {
tableFooterView?.frame = CGRect(x: currentFrame.minX, y: currentFrame.minY, width: currentFrame.width, height: newHeight)
}
return adjustmentNeeded
}
private let minFooterHeight: CGFloat = 44
private func calcFooterHeight() -> CGFloat {
guard let footerView = tableFooterView else { return 0 }
let spaceTaken = contentSize.height - footerView.bounds.height
let spaceAvailable = bounds.height - spaceTaken
return spaceAvailable > minFooterHeight ? spaceAvailable : minFooterHeight
}
}
UITableViewDelegate has method tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat which we can use to specifiy height of section footers. This method fires when we call reloadData() for table view or when screen orientation was changed, etc.
So you can implement this method to calculate a new height of the footer:
override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
guard section == 0 else { return 0.0 } // assume there is only one section in the table
var cellsHeight: CGFloat = 0.0
let rows = self.tableView(tableView, numberOfRowsInSection: section)
for row in 0..<rows
{
let indexPath = IndexPath(item: row, section: section)
cellsHeight += self.tableView(tableView, heightForRowAt: indexPath)
}
let headerHeight: CGFloat = tableView.tableHeaderView?.frame.height ?? 0.0
let footerHeight = view.frame.height - headerHeight - cellsHeight
return footerHeight
}
I arrived at the following solution. Many thanks to all of the folks on this thread for the biggest part of the solution. The TableViewController.TableView class provides the desired functionality. The remainder of the code fleshes out a complete example.
//
// TableViewController.swift
// Tables
//
// Created by Robert Vaessen on 11/6/18.
// Copyright © 2018 Robert Vaessen. All rights reserved.
//
// Note: Add the following to AppDelegate:
//
// func application(_ application: UIApplication,
// didFinishLaunchingWithOptions launchOptions:
// [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// window = UIWindow(frame: UIScreen.main.bounds)
// window?.makeKeyAndVisible()
// window?.rootViewController = TableViewController()
// return true
// }
import UIKit
class TableViewController: UIViewController {
class TableView : UITableView {
override func reloadData() {
execute() { super.reloadData() }
}
override func reloadRows(at indexPaths: [IndexPath], with animation: UITableView.RowAnimation) {
execute() { super.reloadRows(at: indexPaths, with: animation) }
}
private func execute(reload: #escaping () -> Void) {
CATransaction.begin()
CATransaction.setCompletionBlock() {
print("Reload completed")
_ = self.adjustFooter()
}
print("\nReload begun")
reload()
CATransaction.commit()
}
private func adjustFooter() -> Bool {
guard let footerView = tableFooterView else { return false }
func calcFooterHeight() -> CGFloat {
var heightUsed = tableHeaderView?.bounds.height ?? 0
for cell in visibleCells { heightUsed += cell.bounds.height }
let heightRemaining = bounds.height - heightUsed
let minHeight: CGFloat = 44
return heightRemaining > minHeight ? heightRemaining : minHeight
}
let newHeight = calcFooterHeight()
guard newHeight != footerView.bounds.height else { return false }
// Keep the origin where it is, i.e. tweaking just the height expands the frame about its center
let currentFrame = footerView.frame
footerView.frame = CGRect(x: currentFrame.origin.x, y: currentFrame.origin.y, width: currentFrame.width, height: newHeight)
return true
}
}
class FooterView : UIView {
override func draw(_ rect: CGRect) {
print("Drawing footer")
super.draw(rect)
}
}
private var tableView: TableView!
private let cellReuseId = "TableCell"
private let data: [UIColor] = [UIColor(white: 0.4, alpha: 1), UIColor(white: 0.5, alpha: 1), UIColor(white: 0.6, alpha: 1), UIColor(white: 0.7, alpha: 1)]
private var dataRepeatCount = 1
override func viewDidLoad() {
super.viewDidLoad()
func createTable(in: UIView) -> TableView {
let tableView = TableView(frame: CGRect.zero)
tableView.separatorStyle = .none
tableView.translatesAutoresizingMaskIntoConstraints = false
`in`.addSubview(tableView)
tableView.centerXAnchor.constraint(equalTo: `in`.centerXAnchor).isActive = true
tableView.centerYAnchor.constraint(equalTo: `in`.centerYAnchor).isActive = true
tableView.widthAnchor.constraint(equalTo: `in`.widthAnchor, multiplier: 1).isActive = true
tableView.heightAnchor.constraint(equalTo: `in`.heightAnchor, multiplier: 0.8).isActive = true
tableView.dataSource = self
tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellReuseId)
return tableView
}
func addHeader(to: UITableView) {
let header = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 50))
to.tableHeaderView = header
let color = UIColor.black
let offset: CGFloat = 64
let add = UIButton(type: .system)
add.setTitle("Add", for: .normal)
add.layer.borderColor = color.cgColor
add.layer.borderWidth = 1
add.layer.cornerRadius = 5
add.tintColor = color
add.contentEdgeInsets = UIEdgeInsets.init(top: 8, left: 8, bottom: 8, right: 8)
add.addTarget(self, action: #selector(addRows), for: .touchUpInside)
add.translatesAutoresizingMaskIntoConstraints = false
header.addSubview(add)
add.centerXAnchor.constraint(equalTo: to.centerXAnchor, constant: -offset).isActive = true
add.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true
let remove = UIButton(type: .system)
remove.setTitle("Remove", for: .normal)
remove.layer.borderColor = color.cgColor
remove.layer.borderWidth = 1
remove.layer.cornerRadius = 5
remove.tintColor = color
remove.contentEdgeInsets = UIEdgeInsets.init(top: 8, left: 8, bottom: 8, right: 8)
remove.addTarget(self, action: #selector(removeRows), for: .touchUpInside)
remove.translatesAutoresizingMaskIntoConstraints = false
header.addSubview(remove)
remove.centerXAnchor.constraint(equalTo: header.centerXAnchor, constant: offset).isActive = true
remove.centerYAnchor.constraint(equalTo: header.centerYAnchor).isActive = true
}
func addFooter(to: UITableView) {
let footer = FooterView(frame: CGRect(x: 0, y: 0, width: 0, height: 50))
footer.layer.borderWidth = 3
footer.layer.borderColor = UIColor.red.cgColor
//footer.contentMode = .redraw
to.tableFooterView = footer
}
tableView = createTable(in: view)
addHeader(to: tableView)
addFooter(to: tableView)
view.backgroundColor = .white
tableView.backgroundColor = .black // UIColor(white: 0.2, alpha: 1)
tableView.tableHeaderView!.backgroundColor = .cyan // UIColor(white: 0, alpha: 1)
tableView.tableFooterView!.backgroundColor = .white // UIColor(white: 0, alpha: 1)
}
#objc private func addRows() {
dataRepeatCount += 1
tableView.reloadData()
}
#objc private func removeRows() {
dataRepeatCount -= dataRepeatCount > 0 ? 1 : 0
tableView.reloadData()
}
}
extension TableViewController : UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
guard section == 0 else { fatalError("Unexpected section: \(section)") }
return dataRepeatCount * data.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
cell.textLabel?.textAlignment = .center
cell.backgroundColor = data[indexPath.row % data.count]
cell.textLabel?.textColor = .white
cell.textLabel?.text = "\(indexPath.row)"
return cell
}
}

2-way scrolling Table in iOS

I am an android application developer and new to iOS programming and my very first challenge is to build a 2-way scrolling table in iOS. I am getting many solutions with UICollectionView inside UITableView. But in my case rows will scroll together, not independent of each other. There are more than 15 columns and 100+ rows with text data in the table.
I have achieved the same in Android by using a ListView inside a HorizontalScrollView. But yet to find any solution in iOS. Any help is greatly appreciated.
EDIT: I have added a couple of screens of the android app where the table is scrolled horizontally.
So you want this:
You should use a UICollectionView. You can't use UICollectionViewFlowLayout (the only layout that's provided in the public SDK) because it is designed to only scroll in one direction, so you need to implement a custom UICollectionViewLayout subclass that arranges the elements to scroll in both directions if needed.
For full details on building a custom UICollectionViewLayout subclass, you should watch these: videos from WWDC 2012:
Session 205: Introducing Collection Views
Session 219: Advanced Collection Views and Building Custom Layouts
Anyway, I'll just dump an example implementation of GridLayout here for you to start with. For each IndexPath, I use the section as the row number and the item as the column number.
class GridLayout: UICollectionViewLayout {
var cellHeight: CGFloat = 22
var cellWidths: [CGFloat] = [] {
didSet {
precondition(cellWidths.filter({ $0 <= 0 }).isEmpty)
invalidateCache()
}
}
override var collectionViewContentSize: CGSize {
return CGSize(width: totalWidth, height: totalHeight)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// When bouncing, rect's origin can have a negative x or y, which is bad.
let newRect = rect.intersection(CGRect(x: 0, y: 0, width: totalWidth, height: totalHeight))
var poses = [UICollectionViewLayoutAttributes]()
let rows = rowsOverlapping(newRect)
let columns = columnsOverlapping(newRect)
for row in rows {
for column in columns {
let indexPath = IndexPath(item: column, section: row)
poses.append(pose(forCellAt: indexPath))
}
}
return poses
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return pose(forCellAt: indexPath)
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return false
}
private struct CellSpan {
var minX: CGFloat
var maxX: CGFloat
}
private struct Cache {
var cellSpans: [CellSpan]
var totalWidth: CGFloat
}
private var _cache: Cache? = nil
private var cache: Cache {
if let cache = _cache { return cache }
var spans = [CellSpan]()
var x: CGFloat = 0
for width in cellWidths {
spans.append(CellSpan(minX: x, maxX: x + width))
x += width
}
let cache = Cache(cellSpans: spans, totalWidth: x)
_cache = cache
return cache
}
private var totalWidth: CGFloat { return cache.totalWidth }
private var cellSpans: [CellSpan] { return cache.cellSpans }
private var totalHeight: CGFloat {
return cellHeight * CGFloat(collectionView?.numberOfSections ?? 0)
}
private func invalidateCache() {
_cache = nil
invalidateLayout()
}
private func rowsOverlapping(_ rect: CGRect) -> Range<Int> {
let startRow = Int(floor(rect.minY / cellHeight))
let endRow = Int(ceil(rect.maxY / cellHeight))
return startRow ..< endRow
}
private func columnsOverlapping(_ rect: CGRect) -> Range<Int> {
let minX = rect.minX
let maxX = rect.maxX
if let start = cellSpans.firstIndex(where: { $0.maxX >= minX }), let end = cellSpans.lastIndex(where: { $0.minX <= maxX }) {
return start ..< end + 1
} else {
return 0 ..< 0
}
}
private func pose(forCellAt indexPath: IndexPath) -> UICollectionViewLayoutAttributes {
let pose = UICollectionViewLayoutAttributes(forCellWith: indexPath)
let row = indexPath.section
let column = indexPath.item
pose.frame = CGRect(x: cellSpans[column].minX, y: CGFloat(row) * cellHeight, width: cellWidths[column], height: cellHeight)
return pose
}
}
To draw the separating lines, I added hairline views to each cell's background:
class GridCell: UICollectionViewCell {
static var reuseIdentifier: String { return "cell" }
override init(frame: CGRect) {
super.init(frame: frame)
label.frame = bounds.insetBy(dx: 2, dy: 2)
label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
contentView.addSubview(label)
let backgroundView = UIView(frame: CGRect(origin: .zero, size: frame.size))
backgroundView.backgroundColor = .white
self.backgroundView = backgroundView
rightSeparator.backgroundColor = .gray
backgroundView.addSubview(rightSeparator)
bottomSeparator.backgroundColor = .gray
backgroundView.addSubview(bottomSeparator)
}
func setRecord(_ record: String) {
label.text = record
}
override func layoutSubviews() {
super.layoutSubviews()
let thickness = 1 / (window?.screen.scale ?? 1)
let size = bounds.size
rightSeparator.frame = CGRect(x: size.width - thickness, y: 0, width: thickness, height: size.height)
bottomSeparator.frame = CGRect(x: 0, y: size.height - thickness, width: size.width, height: thickness)
}
private let label = UILabel()
private let rightSeparator = UIView()
private let bottomSeparator = UIView()
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Here's my demo view controller:
class ViewController: UIViewController {
var records: [[String]] = (0 ..< 20).map { row in
(0 ..< 6).map {
column in
"Row \(row) column \(column)"
}
}
var cellWidths: [CGFloat] = [ 180, 200, 180, 160, 200, 200 ]
override func viewDidLoad() {
super.viewDidLoad()
let layout = GridLayout()
layout.cellHeight = 44
layout.cellWidths = cellWidths
let collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
collectionView.isDirectionalLockEnabled = true
collectionView.backgroundColor = UIColor(white: 0.95, alpha: 1)
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.register(GridCell.self, forCellWithReuseIdentifier: GridCell.reuseIdentifier)
collectionView.dataSource = self
view.addSubview(collectionView)
}
}
extension ViewController: UICollectionViewDataSource {
func numberOfSections(in collectionView: UICollectionView) -> Int {
return records.count
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return records[section].count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: GridCell.reuseIdentifier, for: indexPath) as! GridCell
cell.setRecord(records[indexPath.section][indexPath.item])
return cell
}
}

Is it possible to create a scroll view with an animated page control in Swift?

The designer wants the following animation from a swipe gesture.
As it can be seen the user can swipe cards and see what each card has. At the same time, the user can see in the right side of the screen the following card and the last one in the left. Also, cards are changing their size while the user is moving the scroll.
I have already worked with page control views but I have no idea if this is possible with a page Control (which actually is the question of this post).
Also, I have already tried with a collectionView but when I swipe (actually is an horizontal scroll) the scroll has an uncomfortable inertia and also, I have no idea how to make the animation.
In this question a scrolled page control is implemented but now I just wondering if and animation like the gif provided is possible.
If the answer is yes, I would really appreciate if you can give tips of how I can make this possible.
Thanks in advance.
Based on the Denislava Shentova comment I found a good library that solves this issue.
For all people in the future and their work hours, I just took code from UPCarouselFlowLayout library and deleted some I didn't need.
Here is the code of a simple viewController that shows the following result:
import UIKit
class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource {
// CollectionView variable:
var collectionView : UICollectionView?
// Variables asociated to collection view:
fileprivate var currentPage: Int = 0
fileprivate var pageSize: CGSize {
let layout = self.collectionView?.collectionViewLayout as! UPCarouselFlowLayout
var pageSize = layout.itemSize
pageSize.width += layout.minimumLineSpacing
return pageSize
}
fileprivate var colors: [UIColor] = [UIColor.black, UIColor.red, UIColor.green, UIColor.yellow]
override func viewDidLoad() {
super.viewDidLoad()
self.addCollectionView()
self.setupLayout()
}
func setupLayout(){
// This is just an utility custom class to calculate screen points
// to the screen based in a reference view. You can ignore this and write the points manually where is required.
let pointEstimator = RelativeLayoutUtilityClass(referenceFrameSize: self.view.frame.size)
self.collectionView?.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true
self.collectionView?.topAnchor.constraint(equalTo: self.view.topAnchor, constant: pointEstimator.relativeHeight(multiplier: 0.1754)).isActive = true
self.collectionView?.widthAnchor.constraint(equalTo: self.view.widthAnchor).isActive = true
self.collectionView?.heightAnchor.constraint(equalToConstant: pointEstimator.relativeHeight(multiplier: 0.6887)).isActive = true
self.currentPage = 0
}
func addCollectionView(){
// This is just an utility custom class to calculate screen points
// to the screen based in a reference view. You can ignore this and write the points manually where is required.
let pointEstimator = RelativeLayoutUtilityClass(referenceFrameSize: self.view.frame.size)
// This is where the magic is done. With the flow layout the views are set to make costum movements. See https://github.com/ink-spot/UPCarouselFlowLayout for more info
let layout = UPCarouselFlowLayout()
// This is used for setting the cell size (size of each view in this case)
// Here I'm writting 400 points of height and the 73.33% of the height view frame in points.
layout.itemSize = CGSize(width: pointEstimator.relativeWidth(multiplier: 0.73333), height: 400)
// Setting the scroll direction
layout.scrollDirection = .horizontal
// Collection view initialization, the collectionView must be
// initialized with a layout object.
self.collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
// This line if for able programmatic constrains.
self.collectionView?.translatesAutoresizingMaskIntoConstraints = false
// CollectionView delegates and dataSource:
self.collectionView?.delegate = self
self.collectionView?.dataSource = self
// Registering the class for the collection view cells
self.collectionView?.register(CardCell.self, forCellWithReuseIdentifier: "cellId")
// Spacing between cells:
let spacingLayout = self.collectionView?.collectionViewLayout as! UPCarouselFlowLayout
spacingLayout.spacingMode = UPCarouselFlowLayoutSpacingMode.overlap(visibleOffset: 20)
self.collectionView?.backgroundColor = UIColor.gray
self.view.addSubview(self.collectionView!)
}
// MARK: - Card Collection Delegate & DataSource
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return colors.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cellId", for: indexPath) as! CardCell
cell.customView.backgroundColor = colors[indexPath.row]
return cell
}
// MARK: - UIScrollViewDelegate
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let layout = self.collectionView?.collectionViewLayout as! UPCarouselFlowLayout
let pageSide = (layout.scrollDirection == .horizontal) ? self.pageSize.width : self.pageSize.height
let offset = (layout.scrollDirection == .horizontal) ? scrollView.contentOffset.x : scrollView.contentOffset.y
currentPage = Int(floor((offset - pageSide / 2) / pageSide) + 1)
}
}
class CardCell: UICollectionViewCell {
let customView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.cornerRadius = 12
return view
}()
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(self.customView)
self.customView.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
self.customView.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
self.customView.widthAnchor.constraint(equalTo: self.widthAnchor, multiplier: 1).isActive = true
self.customView.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 1).isActive = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
} // End of CardCell
class RelativeLayoutUtilityClass {
var heightFrame: CGFloat?
var widthFrame: CGFloat?
init(referenceFrameSize: CGSize){
heightFrame = referenceFrameSize.height
widthFrame = referenceFrameSize.width
}
func relativeHeight(multiplier: CGFloat) -> CGFloat{
return multiplier * self.heightFrame!
}
func relativeWidth(multiplier: CGFloat) -> CGFloat{
return multiplier * self.widthFrame!
}
}
Note that there are some other clases in this code but temporarily you can run the whole code in the ViewController.swift file. After you test, please split them into different files.
In order tu run this code, you need the following module. Make a file called UPCarouselFlowLayout.swift and paste all this code:
import UIKit
public enum UPCarouselFlowLayoutSpacingMode {
case fixed(spacing: CGFloat)
case overlap(visibleOffset: CGFloat)
}
open class UPCarouselFlowLayout: UICollectionViewFlowLayout {
fileprivate struct LayoutState {
var size: CGSize
var direction: UICollectionViewScrollDirection
func isEqual(_ otherState: LayoutState) -> Bool {
return self.size.equalTo(otherState.size) && self.direction == otherState.direction
}
}
#IBInspectable open var sideItemScale: CGFloat = 0.6
#IBInspectable open var sideItemAlpha: CGFloat = 0.6
open var spacingMode = UPCarouselFlowLayoutSpacingMode.fixed(spacing: 40)
fileprivate var state = LayoutState(size: CGSize.zero, direction: .horizontal)
override open func prepare() {
super.prepare()
let currentState = LayoutState(size: self.collectionView!.bounds.size, direction: self.scrollDirection)
if !self.state.isEqual(currentState) {
self.setupCollectionView()
self.updateLayout()
self.state = currentState
}
}
fileprivate func setupCollectionView() {
guard let collectionView = self.collectionView else { return }
if collectionView.decelerationRate != UIScrollViewDecelerationRateFast {
collectionView.decelerationRate = UIScrollViewDecelerationRateFast
}
}
fileprivate func updateLayout() {
guard let collectionView = self.collectionView else { return }
let collectionSize = collectionView.bounds.size
let isHorizontal = (self.scrollDirection == .horizontal)
let yInset = (collectionSize.height - self.itemSize.height) / 2
let xInset = (collectionSize.width - self.itemSize.width) / 2
self.sectionInset = UIEdgeInsetsMake(yInset, xInset, yInset, xInset)
let side = isHorizontal ? self.itemSize.width : self.itemSize.height
let scaledItemOffset = (side - side*self.sideItemScale) / 2
switch self.spacingMode {
case .fixed(let spacing):
self.minimumLineSpacing = spacing - scaledItemOffset
case .overlap(let visibleOffset):
let fullSizeSideItemOverlap = visibleOffset + scaledItemOffset
let inset = isHorizontal ? xInset : yInset
self.minimumLineSpacing = inset - fullSizeSideItemOverlap
}
}
override open func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
override open func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let superAttributes = super.layoutAttributesForElements(in: rect),
let attributes = NSArray(array: superAttributes, copyItems: true) as? [UICollectionViewLayoutAttributes]
else { return nil }
return attributes.map({ self.transformLayoutAttributes($0) })
}
fileprivate func transformLayoutAttributes(_ attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes {
guard let collectionView = self.collectionView else { return attributes }
let isHorizontal = (self.scrollDirection == .horizontal)
let collectionCenter = isHorizontal ? collectionView.frame.size.width/2 : collectionView.frame.size.height/2
let offset = isHorizontal ? collectionView.contentOffset.x : collectionView.contentOffset.y
let normalizedCenter = (isHorizontal ? attributes.center.x : attributes.center.y) - offset
let maxDistance = (isHorizontal ? self.itemSize.width : self.itemSize.height) + self.minimumLineSpacing
let distance = min(abs(collectionCenter - normalizedCenter), maxDistance)
let ratio = (maxDistance - distance)/maxDistance
let alpha = ratio * (1 - self.sideItemAlpha) + self.sideItemAlpha
let scale = ratio * (1 - self.sideItemScale) + self.sideItemScale
attributes.alpha = alpha
attributes.transform3D = CATransform3DScale(CATransform3DIdentity, scale, scale, 1)
attributes.zIndex = Int(alpha * 10)
return attributes
}
override open func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = collectionView , !collectionView.isPagingEnabled,
let layoutAttributes = self.layoutAttributesForElements(in: collectionView.bounds)
else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset) }
let isHorizontal = (self.scrollDirection == .horizontal)
let midSide = (isHorizontal ? collectionView.bounds.size.width : collectionView.bounds.size.height) / 2
let proposedContentOffsetCenterOrigin = (isHorizontal ? proposedContentOffset.x : proposedContentOffset.y) + midSide
var targetContentOffset: CGPoint
if isHorizontal {
let closest = layoutAttributes.sorted { abs($0.center.x - proposedContentOffsetCenterOrigin) < abs($1.center.x - proposedContentOffsetCenterOrigin) }.first ?? UICollectionViewLayoutAttributes()
targetContentOffset = CGPoint(x: floor(closest.center.x - midSide), y: proposedContentOffset.y)
}
else {
let closest = layoutAttributes.sorted { abs($0.center.y - proposedContentOffsetCenterOrigin) < abs($1.center.y - proposedContentOffsetCenterOrigin) }.first ?? UICollectionViewLayoutAttributes()
targetContentOffset = CGPoint(x: proposedContentOffset.x, y: floor(closest.center.y - midSide))
}
return targetContentOffset
}
}
Again, this module was made by Paul Ulric, you can installed with cocoa.

Resources