*Modern* CollectionView. Waterfall effect? (I.e. Is dynamic cell height / staggering for MULTIPLE columns possible?) - swift

Goal:
UICollectionView with 'waterfall effect', entailing:
Multiple columns of cells (each cell contains only one stackview).
Each stackview contains n items, thus height of cells varies, but width is fixed.
Each cell in each column should butt up against one above it, with fixed padding, let's say CGFloat(50.0).
That produces a 'staggered' look across the columns, but no wasted space or 'empty slots'.
The Problem:
I've tried configuring the collection view using UICollectionViewFlowLayout, and alternatively, UICollectionViewCompositionalLayout without success
Best I've been able to achieve is cells aligned to top of each row, where each row has a uniform height (approx same height as tallest cell in that row). Meaning, next row starts below the bottom of the tallest cell in the previous row, leaving large gaps between shorter cells and cells placed cells in the next row (which is what I don't want).
Afterthoughts:
I suspect that's just how it is... that collection view (particularly compositional layout) doesn't know how to handle multiple columns with independent vertical layouts wherein all columns scroll vertically as one.
I'm not even sure if a custom layout could solve it, as I've never written one yet. Or if I'm just better off creating a big scroll view and layout the stackviews manually without a collection view, or multiple single-column collection views side by side? I don't understand enough of the tradeoffs and possibilities and hoping someone can steer me in the right direction.
Note, I did find this cool hack for making the content of cells top align for UICollectionViewFlow layout, and it works for what it is, but doesn't but doesn't give me the waterfall effect I'm looking e.g. doesn't tie multiple rows together for a seamless vertically sized cell height appearance.
UICollection View Flow Layout Vertical Align
class TopAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// This method is being requested to return the layout attributes for each cell in the rectangle.'
// where the attributes are frame, bounds, center, size, transform3D, transform, alpha, zIndix and isHidden
// It starts by getting a copy attributes the super class has configured each cell:
//
let attributes = super.layoutAttributesForElements(in: rect)?.map { $0.copy() } as? [UICollectionViewLayoutAttributes]
attributes?
// Then it's using the reduce() function to build a new dictionary of tupples consisting of:
// [cgfloat : (cgfloat, [attrs])], which is [ cell center.y : (cell origin.y, [all the other cells]) ]
///
.reduce([CGFloat: (CGFloat, [UICollectionViewLayoutAttributes])]()) { // for each cell, $0 = dict to build, $1 = next cell
guard $1.representedElementCategory == .cell else { return $0 } // only operate on cells (not decorations or supplemental views)
return $0.merging([ceil($1.center.y): ($1.frame.origin.y, [$1])]) { // center.y : origin.y, [merged attributes]
($0.0 < $1.0 ? $0.0 : $1.0, $0.1 + $1.1)
//
// if dict center.y < cell center.y
// (cell ctr.y, [cell/dict merged attributes]
// else
// (dict ctr.y, [cell/dict merged attributes]
}
} // returns dictionary of merged [cgfloat : (cgfloat, [attrs])]
.values.forEach { minY, line in // pull each item up to the top via diff btw center and top line
line.forEach {
$0.frame = $0.frame.offsetBy(
dx: 0,
dy: minY - $0.frame.origin.y
)
$0.frame = CGRectMake($0.frame.origin.x, $0.frame.origin.y + 200, $0.frame.size.width, 50) //$0.frame.size.height - (minY - $0.frame.origin.y))
}
}
return attributes
}
}

Related

UICollectionViewFlowLayout with multiple columns and dynamic cell sizing

My goal is to implement a collection that looks like so:
Instagram's discover tab
I can achieve a similar result using UICollectionViewCompositionalLayout but I am constrained to iOS versions lower than 13.0, which makes this a no go.
I have been testing UICollectionViewFlowLayout and I can achieve multiple columns using collectionView(_:layout:sizeForItemAt:), but I can't find a way to dynamically calculate the height of the cells, which can be either an image, a label, etc.
Thanks
You need to subclass UICollectionViewFlowLayout which will provide you an opportunity to all the modifications to cell frames and more.
import UIKit
class InstagramCollectionViewFlowLayout: UICollectionViewFlowLayout {
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// These attributes have all the information about how
// collectionView's default layout will layout your cells
guard let attributes = super.layoutAttributesForElements(in: rect) else { return nil }
guard let collectionView = self.collectionView else { return nil }
var newAttributes: [UICollectionViewLayoutAttributes] = []
let layout = collectionView.collectionViewLayout
for attribute in attributes {
if let cellAttribute = layout.layoutAttributesForItem(at: attribute.indexPath) {
// Decide which indexPaths' cells should appear bigger
if attribute.indexPath.item % 2 == 0 {
// whatever modifications you want for big cells
cellAttribute.frame.size = CGSize(width: 200, height: 200)
}
else {
// whatever modifications you want for small cells
cellAttribute.frame.size = CGSize(width: 70, height: 70)
}
// Since you are modifying default layout and making some cells bigger
// You will need to accoun for extra height in your calculations
// I am not doing that here
newAttributes.append(cellAttribute)
}
}
return newAttributes
}
}
Usage
let igFlowLayout = InstagramCollectionViewFlowLayout()
collectionView.collectionViewLayout = igFlowLayout
Notes
This is just an idea of how you are supposed to create a subclass to customize cell frames. You will need to add a lot more to this according to your own implementation.
Subclassing UICollectionViewLayout also means that now you are fully responsible for telling collectionView how big it's collectionViewContentSize should be for all your data.
Reference
Customizing Collection View Layouts has all the information you need to get started on this. They explain how to achieve a similar layout in their example.

Page control in compositional layout collection view

I'm trying to use pageControl option in compositional layout collection view.I already referred some previous questions related to same topic, like this Trying to hook up Compositional Layout CollectionView with PageControl. visibleItemsInvalidationHandler is not calling .It didn't work for me.Please help me if there is a way to achieve this.Your support is much appreciated.
section.visibleItemsInvalidationHandler = { [weak self] (items, offset, env) -> Void in
guard let self = self,
let itemWidth = items.last?.bounds.width else { return }
// This offset is different from a scrollView. It increases by the item width + the spacing between items.
// So we need to divide the offset by the sum of them.
let page = round(offset.x / (itemWidth + section.interGroupSpacing))
self.didChangeCollectionViewPage(to: Int(page))
}
As I commented in the code snippet, the offset here is different, it sums the item width and the section spacing, so instead of dividing the offset by the content width, you need to divide it by the item width and the intergroup spacing.
It may not help you if you have different item widths, but I'm my case, where all the items have the same width, it works.

How can I implement this UICollectionView cell animation?

I've been wanting to implement this nice little UICollectionViewCell animation shown below that was on Dribble.
Do you think it's possible?
I looked around for any guides to get a head start on this but found nothing quite similar.
I have the idea that a custom flow layout is the way to go here. Would it be that I will have to make snapshots of each visible cell, add pan gestures to each cell and based on the movement detected through the gesture recogniser, capture visible cells and animate the snapshot images? Would appreciate any help to understand how I could implement this.
Thank you.
This is a pretty interesting challenge.
Instead of doing a custom layout, I would override scrollViewDidScroll, store the offset every time it's called, compare it with the last stored offset in order to get the velocity, and based off of that, apply a transform to all visibleCells in your collection view.
var lastOffsetX: CGFloat?
func scrollViewDidScroll(_ scrollView: UIScrollView) {
defer { lastOffsetX = scrollView.contentOffset.x }
guard let lastOffsetX = lastOffsetX else { return }
// You'll have to evaluate how large velocity gets to avoid the cells
// from stretching too much
let maxVelocity: CGFloat = 60
let maxStretch: CGFloat = 10
let velocity = min(scrollView.contentOffset.x - lastOffsetX, maxVelocity)
let stretch = velocity / maxVelocity * maxStretch
var cumulativeStretch: CGFloat = 0
collectionView.visibleCells.forEach { cell in
cumulativeStretch += stretch
cell.transform = CGAffineTransform(translateX: cumulativeStretch, y: 0)
}
}
I would start with something like this, and make lastOffsetX = nil when the scroll view stops scrolling (this exercise is left to the reader).
It will probably require some tweaking.

Consistent curves with dynamic corner radius (Swift)?

Is there a way to make the corner radius of a UIView adept to the view it belongs to? I'm not comfortable with the idea of hard-coding corner radius values, because as soon as the width or the height of your view changes (on different screen orientations for example), the corners will look totally different. For example, take a look at WhatsApp's chat window.
As you can see, every message container view has a different width and a different height, but the curve of the corners are all exactly the same. This is what I'm trying to achieve. I want the curves of my corners to be the same on every view, no matter what the size of the view is or what screen the view is displayed on. I've tried setting the corner radius relative to the view's height (view.layer.cornerRadius = view.frame.size.height * 0.25) and I've also tried setting it to the view's width, but this doesn't work. The corners still look weird as soon as they are displayed on a different screen size. Please let me know if there's a certain formula or trick to make the curves look the same on every view/screen size.
Here's the best I can do. I don't know if this will be of help, but hopefully it will give you some ideas.
First the code:
class ViewController: UIViewController {
let cornerRadius:CGFloat = 10
let insetValue:CGFloat = 10
var numberOfViews:Int = 0
var myViews = [UIView]()
override func viewDidLoad() {
super.viewDidLoad()
view.translatesAutoresizingMaskIntoConstraints = false
}
override func viewWillLayoutSubviews() {
setNumberOfViews()
createViews()
createViewHierarchy()
addConstraints()
}
func setNumberOfViews() {
var smallerDimension:CGFloat = 0
if view.frame.height < view.frame.width {
smallerDimension = view.frame.height
} else {
smallerDimension = view.frame.width
}
let viewCount = smallerDimension / (insetValue * 2)
numberOfViews = Int(viewCount)
}
func createViews() {
for i in 1...numberOfViews {
switch i % 5 {
case 0:
myViews.append(MyView(UIColor.black, cornerRadius))
case 1:
myViews.append(MyView(UIColor.blue, cornerRadius))
case 2:
myViews.append(MyView(UIColor.red, cornerRadius))
case 3:
myViews.append(MyView(UIColor.yellow, cornerRadius))
case 4:
myViews.append(MyView(UIColor.green, cornerRadius))
default:
break
}
}
}
func createViewHierarchy() {
view.addSubview(myViews[0])
for i in 1...myViews.count-1 {
myViews[i-1].addSubview(myViews[i])
}
}
func addConstraints() {
for view in myViews {
view.topAnchor.constraint(equalTo: (view.superview?.topAnchor)!, constant: insetValue).isActive = true
view.leadingAnchor.constraint(equalTo: (view.superview?.leadingAnchor)!, constant: insetValue).isActive = true
view.trailingAnchor.constraint(equalTo: (view.superview?.trailingAnchor)!, constant: -insetValue).isActive = true
view.bottomAnchor.constraint(equalTo: (view.superview?.bottomAnchor)!, constant: -insetValue).isActive = true
}
}
}
class MyView: UIView {
convenience init(_ backgroundColor:UIColor, _ cornerRadius:CGFloat) {
self.init(frame: CGRect.zero)
self.translatesAutoresizingMaskIntoConstraints = false
self.backgroundColor = backgroundColor
self.layer.cornerRadius = cornerRadius
}
}
Explanation:
This is fairly simple code. The intent was to create as deeply nested a view hierarchy as possible, and, using auto layout, have two main variables: cornerRadius (the view's corner radius) and insetValue (the "frame's" inset). These two variables can be adjusted for experimenting.
The bulk of the logic is in viewWillLayoutSubviews, where the root view frame size is know. Since I'm using 5 different background colors, I'm calculating how many views can fit in the hierarchy. Then I'm creating them, followed by creating the view hierarchy, and finally I'm adding the constraints.
Experimenting and conclusions:
I was able to see what your concern is - yes, if a view's size components are smaller than the corner radius, you end up with inconsistent looking corners. But these values are pretty small - pretty much 10 or less. Most views are unusable at that size. (If I recall even the HIG suggests that a button should be no less than 40 points in size. Sure, even Apple breaks that rule. Still.)
If your 'insetValueis sufficiently larger than the corner radius, you should never have an issue. Likewise, using the iMessage scenario, a singleUILabelcontaining text and/or emoticons should have enough height that a noticeablecornerRadius` can be had.
The key point to set things like cornerRadius and insetValue is in viewWillLayoutSubviews, when you can decide (1) which is the smaller dimension, height or width, (2) how deeply you can nest views, and (3) how large of a corner radius you can set.
Use auto layout! Please note the absolute lack of frames. Other than determining the root view's dimensions at the appropriate time, you can write very compact code without worrying about device size or orientation.

How to determine if a cell has scrolled off screen XCUI

I have a XCUI test case in swift where I am trying to determine if a cell has scrolled off screen. However, I've noticed that once a cell has been on screen the static text is always findable, even when the cell scrolls off screen, when using
XCTAssertTrue(app.tables.cells.StaticText["person"].exists)
This also does not work for me
let window = app.windows.elementBoundByIndex(0)
let element = app.tables.cells.staticTexts["person"]
XCTAssertTrue(CGRectContainsRect(window.frame, element.frame))
As that second test will pass, even when the cell has scrolled off screen.
Is there a way to determine whether a table cell is no longer within the view on the screen?
Use the hittable API on XCUIElement to determine if an element both exists and is on screen. You should use it on the cell.
Note: hittable will become isHittable in Swift 3.
let cell = app.tables.cells.containingType(.StaticText, identifier: "person").elementBoundByIndex(0)
XCTAssertTrue(cell.hittable)
A solution to this problem, kind of clunky but works, is that I used a function like this
func tapOnSpecifiedPointOnList(cellNumber: Float) {
let cellSpacing: Float = 75
let xCoordinate: Float = 25
let yOffSet: Float = 90
let yCoordinate = yOffSet + (cellSpacing * cellNumber)
let pointToTap = CGPointMake(CGFloat(xCoordinate), CGFloat(yCoordinate))
map().tapAtPosition(pointToTap)
}
tapOnSpecifiedPointOnList(0)
let startingCell = app.cells.otherElements["callout"].elementBoundByIndex(0).label
app.cells.otherElements["callout"].swipeUp()
for i in 0...numberOfCells {
tapOnSpecifiedPointOnList(i)
let nextCell = app.cells.otherElements["callout"].elementBoundByIndex(0).label
XCTAssertNotEqual(startingCell, nextCell)
}
When a cell was tapped, it opened up a separate callout, which didn't exist before. By attaching an accessibilityId on this callout object, I was able to grab the info from the cell and compare it to other cells which I tapped on. Therefore, if I tapped on all possible cell locations on screen, and none of callouts matched the original, it must have gone offscreen.