NSCollectionView interitem gap indicator has wrong height - swift

I'm implementing an application for macOS in which I use an NSCollectionView as a sort of timeline. For this I use a custom subclass of NSCollectionViewFlowLayout for the following reasons:
I want the items to be scrollable horizontally only without wrapping to the next line
Only NSCollectionViewFlowLayout seems to be capable of telling NSCollectionView to not scroll vertically (inspiration from here)
All of this works smoothly, however: I'm now trying to implement drag & drop reordering of the items. This also works, but I noticed that the inter item gap indicator (blue line) is not being displayed properly, even though I do return proper sizes. I calculate them as follows:
override func layoutAttributesForInterItemGap(before indexPath: IndexPath) -> NSCollectionViewLayoutAttributes?
{
var result: NSCollectionViewLayoutAttributes? = nil
// The itemLayoutAttributes dictionary is created in prepare() and contains
// the layout attributes for every item in the collection view
if indexPath.item < itemLayoutAttributes.count
{
if let itemLayout = itemLayoutAttributes[indexPath]
{
result = NSCollectionViewLayoutAttributes(forInterItemGapBefore: indexPath)
result?.frame = NSRect(x: itemLayout.frame.origin.x - 4, y: itemLayout.frame.origin.y, width: 3, height: itemLayout.size.height)
}
}
else
{
if let itemLayout = itemLayoutAttributes.reversed().first
{
result = NSCollectionViewLayoutAttributes(forInterItemGapBefore: indexPath)
result?.frame = NSRect(x: itemLayout.value.frame.origin.x + itemLayout.value.frame.size.width + 4, y: itemLayout.value.frame.origin.y, width: 3, height: itemLayout.value.size.height)
}
}
return result
}
Per documentation the indexPath passed to the method can be between 0 and the number of items in the collection view including if we're trying to drop after the last item. As you can see I return a rectangle for the inter item gap indicator that should be 3 pixels wide and the same height as an item is.
While the indicator is displayed in the correct x-position with the correct width, it is only 2 pixels high, no matter what height I pass in.
How do I fix this?
NB: If I temporarily change the layout to NSCollectionViewFlowLayout the indicator displays correctly.
I'm overriding the following methods/properties in NSCollectionViewFlowLayout:
prepare()
collectionViewContentSize
layoutAttributesForElements(in rect: NSRect) -> [NSCollectionViewLayoutAttributes]
layoutAttributesForItem(at indexPath: IndexPath) -> NSCollectionViewLayoutAttributes?
shouldInvalidateLayout(forBoundsChange newBounds: NSRect) -> Bool
layoutAttributesForInterItemGap(before indexPath: IndexPath) -> NSCollectionViewLayoutAttributes?

You may also need to override
layoutAttributesForDropTarget(at pointInCollectionView: NSPoint) -> NSCollectionViewLayoutAttributes.
The documentation mentions that the frame of the attributes returned by this method provides a bounding box for the gap, that will be used to position and size a drop target indicator (view).
You can set a breakpoint in your drag and drop code, after the drop indicator is drawn (e.g. collectionView(:acceptDrop:indexPath:dropOperation:) and then when the breakpoint is hit during a drag-and-drop session, run Xcode view debugger to examine the drop indicator view after it's added to the collection view. This way you can be sure the view hierarchy is correct and any issues are visually apparent.

Related

How to size a XIB cell so that 2 labels can appear?

I'm smashing my head against the wall for the past 3 hours trying to figure this out. After watching countless tutorials, I still can't seem to figure out why this XIB cell cannot show both labels on the tableview.
XIB file:
What it keeps showing up as:
I just don't understand. I've tried everything, setting height constraints, distance constraints, stack views, but nothing will get the second label on the bottom to show up in the table view cell. Is there an obvious thing I am missing here?
OK, this is absolutely insipid but I figured it out, by randomly copying and pasting bits of code online until something worked. Here's what worked (swiped from hackingwithswift)
override func awakeFromNib() {
super.awakeFromNib()
NSLayoutConstraint.activate([
titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor,constant: 5),
titleLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
titleLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
titleLabel.bottomAnchor.constraint(equalTo: projLabel.topAnchor),
projLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor),
projLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
projLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
projLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
final result:
Try this:
Select TableView in Storyboard -> Identity Inspector
Set Row Height to Automatic (let empty)
Set estimate to some value (for example 150)
On cell class implement this method (example):
override func sizeThatFits(_ size: CGSize) -> CGSize {
// 1) Set the contentView's width to the specified size parameter
contentView.pin.width(size.width)
// 2) Layout the contentView's controls
layout()
// 3) Returns a size that contains all controls
return CGSize(width: contentView.frame.width, height: adsViewsCount.frame.maxY + padding)
}
Implement this with UITableViewDatasource:
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
// The UITableView will call the cell's sizeThatFit() method to compute the height.
// WANRING: You must also set the UITableView.estimatedRowHeight for this to work.
return UITableView.automaticDimension
}

how to adjust layout of tableview cells having collectionView on screen rotation on all screen sizes?

I have CollectionView embedded in TableView cells to show images posted by a user. Im targeting this app on every device from iPhone 8 to Mac and I do have screen rotation on for iPhones and iPads. This is my first time developing app for all platforms & dealing with screen rotation & after searching online I came up with many solutions for handling layout on screen rotation but none of them worked for TableView cells that contain CollectionView and are repeated.
Here I would like to know the best solutions among these for different situations and how to use them to handle CollectionView inside TableView cells.
The first solution I implemented is to handle everything by overriding viewWillLayoutSubviews() in my main View Controller and wrote something like this:
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
let index = IndexPath(row: 0, section: 0)
guard let cell = tblHome.cellForRow(at: index) as? PriorityTaskTableViewCell else {
return
}
guard let flowLayout = cell.cvPriorityTask.collectionViewLayout as? UICollectionViewFlowLayout else {
return
}
flowLayout.invalidateLayout()
cell.layoutIfNeeded()
cell.heightCvPriorityTask.constant = cell.height + 40
}
I had to add cell.layoutIfNeeded() & cell.heightCvPriorityTask.constant = cell.height + 40 to update cell height after rotation. The above approach worked fine for me on every device but only for one TableView cell as you can see I can access CollectionView present in 1st row of TableView only. This approach did not help me deal with situation where I have multiple rows with CollectionView like in case of a social media feed.
Then I decided to deal with rotation in TableView Cell Subclass and came up with following code inside PriorityTaskTableViewCell:
weak var weakParent: HomeViewController?
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
cvPriorityTask.contentInset = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
cvPriorityTask.delegate = self
cvPriorityTask.dataSource = self
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
guard
let previousTraitCollection = previousTraitCollection,
self.traitCollection.verticalSizeClass != previousTraitCollection.verticalSizeClass ||
self.traitCollection.horizontalSizeClass != previousTraitCollection.horizontalSizeClass
else {
return
}
self.cvPriorityTask?.collectionViewLayout.invalidateLayout()
weakParent?.tblHome.updateConstraints()
DispatchQueue.main.async {
self.cvPriorityTask?.reloadData()
self.weakParent?.tblHome.updateConstraints()
}
}
func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
viewWillTransition(to: size, with: coordinator)
self.cvPriorityTask?.collectionViewLayout.invalidateLayout()
weakParent?.tblHome.updateConstraints()
coordinator.animate(alongsideTransition: { context in
}, completion: { context in
self.cvPriorityTask?.collectionViewLayout.invalidateLayout()
self.weakParent?.tblHome.updateConstraints()
})
}
And this is how I'm setting up CollectionView Layout:
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
var width = 0.0
if traitCollection.horizontalSizeClass == .compact {
width = (weakParent?.view.frame.size.width ?? 200) * 0.88
}
else {
width = (weakParent?.view.frame.size.width ?? 200) * 0.425
}
if traitCollection.userInterfaceIdiom == .mac {
height = width * 0.75
}
else {
height = width * 1.13
}
return CGSize(width: width, height: height)
}
This is again another modified code and it seems to work perfectly for small devices like iPhone 8. But does not have any impact on larger displays on rotation. Maybe it works only for traitCollection.horizontalSizeClass == .compact If I could get this working I would have solved my issue for dealing my repeating CollectionView inside multiple rows but I have no idea why it doesn't work on iPhone 13 Pro Max or iPads.
After trying for hours I removed code and just called cvPriorityTask.collectionViewLayout.invalidateLayout() in layoutSubviews() function and it worked too for all cells so I removed all above code from Cell subclass and left with this only:
override func layoutSubviews() {
super.layoutSubviews()
cvPriorityTask.collectionViewLayout.invalidateLayout()
}
This alone made sure collectionView embedded in TableView cells were changing layout as expected but they started cutting TableView cell as main TableView wasn't being updated so I modified my code to somehow refresh TableView too and came up with something like this:
override func layoutSubviews() {
super.layoutSubviews()
cvPriorityTask.collectionViewLayout.invalidateLayout()
layoutIfNeeded()
heightCvPriorityTask.constant = height + 40
weakParent?.tblHome.rowHeight = UITableView.automaticDimension
weakParent?.tblHome.updateConstraints()
}
It did not work at all. If somehow I could update layout of the main table too from the cell subclass.
So im confused which should be right approach. The first one seemed to be most common one I find on internet but I don't know how to handle multiple cells containing CollectionViews with that approach.
Second one works only for iPhone 8 and I have no idea why. And I don't think reloading collectionView is the right approach but it works for small screens so I wouldn't mind if somehow works for large screens.
The third one seems totally wrong to me but actually works on perfectly for every device. However only cell layout is adjusted and I tried to update layout of whole tableView from Cell Subclass but no luck.
So how to deal with layout issues for CollectionView which is embedded in TableView cells? Am I on right track or there is another better way to deal with this situtaion?

Swift 3.0 - Vertical lines on TableViewCell for every CellIndentationLevel

I have an app that has a bunch of collapsible comments.
Each cell that holds a comment has an indentation cell and I would like to draw a vertical line the height of the cell for every indentation level. The end goal is to look something like this: Taken from Reddit
I tried to add a rectangle shaped view for every indent level in CellForRowAt but it would just keep adding onto itself whenever I scrolled out of view and back into it.
Currently I have it working in the "willDisplay cell" function but it only loads it when the ENTIRE cell is visible, not partially and it still has some issues of overlapping content with lines.
Here's my current code:
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
for cell in commentTable.visibleCells {
if cell.indentationLevel > 0 {
for i in 1...cell.indentationLevel {
let rect = UIView(frame: CGRect(x: i * 15, y: 0, width: 1, height: Int(cell.bounds.height)))
cell.addSubview(rect)
}
}
}
}
How can I do this preferably in cellForRowAt function, or the simplest and most efficient way to get this done?
You can do this in cellForRowAtIndexPath. Note that you should not just add views to your cells without tracking them because the cells are reused (thats why its called dequeResuableCell). Make a property called indentationViews and a function called setIndentationLevel. In cellForRowAtIndexPath you call cell.setIndentationLevel (note that I have removed your if because for 0..
func setIndentationLevel(indentationLevel: Int) {
for i in 0..<cell.indentationLevel {
let view = UIView(frame: CGRect(x: i * 15 + 15, y: 0, width: 1, height: Int(cell.bounds.height)))
cell.addSubview(view)
indentationViews.append(view)
}
}
You also need to implement prepare for reuse to kill all of the views you just added before the cell gets used again:
override func prepareForReuse() {
indentationViews.forEach{$0.removeFromSuperview()}
indentationViews.removeAll()
}

TableView only loading visible cells (top 4 sections out of 12 section table view)

I'm trying to figure out why my tableView is only rendering sections that are visible to the user when the viewDidAppear method is called.
When I first launch the application, sections 1, 2, and 3 render correctly as seen below. However, sections 4 and later don't render correctly. When I press the refresh button (manually calling the reload() method), the visible sections during the time of clicking the refresh button refresh and render correctly.
I have linked my viewDidAppear method, as well as a temporary "Refresh" button, to the reload() method below:
textViewArray = [m1DF,m2DF,m3DF,m4DF,m5DF,m6DF,m7DF,m8DF,m9DF,m10DF,m11DF,m12DF,m13DF,m14DF,m15DF,m16DF]
for (index, tv) in textViewArray!.enumerate() {
let fixedWidth = tv.frame.size.width
tv.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.max))
let newSize = tv.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.max))
var newFrame = tv.frame
newFrame.size = CGSize(width: max(newSize.width, fixedWidth), height: newSize.height)
tv.frame = newFrame;
heightArray[index] = newSize.height
}
for (index, _) in (heightArray.enumerate()) {
heightArray[index] = textViewArray![index].frame.height
}
self.tableView.reloadData()
When I first launch the application, sections 1, 2, and 3 render correctly as seen below:
However, sections 4 and later don't render correctly:
When I press the refresh button (manually calling the reload() method), the visible sections during the time of clicking the refresh button (in this case, sections titled "M05" and "M06") refresh and render correctly:
EDIT
Full code excluding outlets:
import UIKit
import AdSupport
import iAd
class CalculatorTableViewController: UITableViewController {
var heightArray: [CGFloat] = [44.0,44.0,44.0,44.0,44.0,44.0,44.0,44.0,44.0,44.0,44.0,44.0,44.0,44.0,44.0,44.0]
var textViewArray = [UITextView]?()
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
reload()
}
func reload() {
textViewArray = [m1DF,m2DF,m3DF,m4DF,m5DF,m6DF,m7DF,m8DF,m9DF,m10DF,m11DF,m12DF,m13DF,m14DF,m15DF,m16DF]
print("*****1*****\n\n\n\n\n \(m1DF.bounds.height), \(m1DF.frame.height)")
for (index, tv) in textViewArray!.enumerate() {
let fixedWidth = tv.frame.size.width
tv.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.max))
let newSize = tv.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.max))
var newFrame = tv.frame
newFrame.size = CGSize(width: max(newSize.width, fixedWidth), height: newSize.height)
tv.frame = newFrame;
heightArray[index] = newSize.height
print("Row \(index) has height \(newSize.height)")
}
for (index, _) in (heightArray.enumerate()) {
heightArray[index] = textViewArray![index].frame.height
}
self.tableView.reloadData()
}
override func viewDidLoad() {
super.viewDidLoad()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
}
override func tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) {
//sizeCell(indexPath.section)
}
#IBAction func refreshButton(sender: AnyObject) {
reload()
}
override func tableView(tableView: UITableView, estimatedHeightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
return 300.0
}
override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
if indexPath.row == 0 {
return heightArray[indexPath.section]
} else {
return UITableViewAutomaticDimension
}
}
/*override func tableView(tableView: UITableView, estimatedHeightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
return 200.0
}*/
}
looking through your code, I think you are getting confused over the key principles of implementing a table view in UIKit. It can be intimidating and I certainly found it confusing at first! You should look through the reference at:
https://developer.apple.com/library/ios/documentation/UIKit/Reference/UITableView_Class/index.html#//apple_ref/occ/cl/UITableView
This outlines in more detail a number of things I'm about to talk about.
Essentially, a UiTableViewController needs to do two things: first, it needs to know where to get the data from to populate the tables; and second, it needs to know how to put the table together in the way you want and present it to the user.
For the first of these tasks - call it the data task, it's actually quite easy. You just need to ensure the table view controller has access to a data object which encapsulates all the data you need. There is a dedicated protocol for this too - UITableViewDataSource - but you don't need to be this sophisticated. I've implemented tables with anything from arrays of strings to using dedicated data manager classes working in much higher dimensions.
The second of these tasks - call it the presentation task - can be the more confusing. The way in which you configure your table view is via delegate methods which come ready packaged with the UITableViewController object (as it is the table view's delegate by default).
Perhaps the most important of these is: cellForRowAtIndexPath:. This function tells the view what to put at each index path in the table. You use the index path provided in the delegate function to locate the data you need (for instance, cell.text = stringData[indexPath.row] in a simple case) and you then tailor the view and any subviews to present that data as you would want to. I'm assuming you know how to do this. One thing that can be problematic with implementing cellForRowAtIndexPath: is that you need to re-use a dequeued cell rather than creating a new cell every time you need one. The way you do this is: first, tell the table view controller which class you are using for your reusable cells with a call to registerClass:forCellReuseIdentifier. In the standard case the class you would register would be UiTableViewCell, but you can use your own too. You can then grab one of these cells (which will be a cell that is no longer being used, I.e. It has scrolled off the screen) with a call to dequeueReusableCellWithIdentifier.
The other key function that you will need is: numberOfRowsInSection(_ section: Int) which tells the controller how many rows in each section of the table (if there's only one section it is of course the number of rows in the table). You use this with the UITableView variable numberOfSections and UITableView rowHeight when initializing the table to set these basic parameters.
Putting this all together, there is no need to set the height for each row in the table (as the array you have used apears to be trying to do), nor to have an explicit reload() function - there is a function "built in" to do this which simply calls the methods I have already alluded to.
For the sake of not making this one of the longest posts I've written I'll leave it there! However, if you'd like me to send over some sample code I'd be happy to do so, although it's probably better for you to play around with what I've briefly outlined above - hope that helps!

UITableView - How to keep table rows fixed as user scrolls

I'd like to be able to fix the position of certain rows in a UITableView as the user scrolls.
Specifically, I have a table whereby certain rows are "headers" for the rows that follow, and I'd like the header to stay at the top of the screen as the user scrolls up. It would then move out of the way when the user scrolls far enough that the next header row would take its place.
A similar example would be the Any.DO app. The "Today", "Tommorrow" and "Later" table rows are always visible on the screen.
Does anyone have any suggestions about how this could be implemented?
I'm currently thinking of follow the TableDidScroll delegate and positioning my own cell in the appropriate place in front of the table view. The problem is that at other times I'd really like these cells to be real table cells so that they can be, for example, reordered by the user.
Thanks,
Tim
I've been playing about with this and I've come up with a simple solution.
First, we add a single UITableViewCell property to the controller. This should be initialize such that looks exactly like the row cells that we'll use to create the false section headers.
Next, we intercept scrolling of the table view
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
// Add some logic here to determine the section header. For example, use
// indexPathsForVisibleRows to get the visible index paths, from which you
// should be able to get the table view row that corresponds to the current
// section header. How this works will be implementation dependent.
//
// If the current section header has changed since the pervious scroll request
// (because a new one should now be at the top of the screen) then you should
// update the contents.
IndexPath *indexPathOfCurrentHeaderCell = ... // Depends on implementation
UITableViewCell *headerCell = [self.tableView cellForRowAtIndexPath:indexPathOfCurrentHeaderCell];
// If it exists then it's on screen. Hide our false header
if (headerCell)
self.cellHeader.hidden = true;
// If it doesn't exist (not on screen) or if it's partially scrolled off the top,
// position our false header at the top of the screen
if (!headerCell || headerCell.frame.origin.y < self.tableView.contentOffset.y )
{
self.cellHeader.hidden = NO;
self.cellHeader.frame = CGRectMake(0, self.tableView.contentOffset.y, self.cellHeader.frame.size.width, self.cellHeader.frame.size.height);
}
// Make sure it's on top of all other cells
[self.tableView bringSubviewToFront:self.cellHeader];
}
Finally, we need to intercept actions on that cell and do the right thing...
That's the default behavior for section headers in plain UITableView instances.
If you want to create a custom header, implement the tableView:viewForHeaderInSection: method in your table view delegate and return the view for your header.
Although you will have to manage sections and rows instead of just rows.
Swift 5 solution
var header: UIView?
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(indexPath: indexPath) as UITableViewCell
header = cell.contentView
return cell
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let headerCell = tableView.cellForRow(at: IndexPath(row: 0, section: 0))
guard headerCell == nil || (headerCell!.frame.origin.y < self.tableView.contentOffset.y + headerCell!.frame.height/2) else {
header?.isHidden = true
return
}
guard let hdr = header else { return }
hdr.isHidden = false
hdr.frame = CGRect(x: 0, y: tableView.contentOffset.y, width: hdr.frame.size.width, height: hdr.frame.size.height)
if !tableView.subviews.contains(hdr) {
tableView.addSubview(hdr)
}
tableView.bringSubviewToFront(hdr)
}