How is it possible to scroll to item and set the horizontal collectionView UICollectionView.ScrollPosition to the exact position of the item (not the .right, .left etc) and at the same time enable the scroll to the user? I tried this:
var selectedLineIdexPath: IndexPath? {
didSet {
if let index = self.selectedLineIdexPath {
self.linesCollectionView.scrollToItem(at:index, at: .centeredHorizontally, animated: false)
}
}
}
Where the selectedIndexPath is set on the:
final func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath)
depending on the value of my data.
The result is that the collectionView scroll to this item but when I try to select fetch and select another one its automatically scroll to the center.
Related
I have implemented expand/ collapse animation, for a UICollectionView with dynamic cell height (Because different cell has different content).
This is the summary of my implementation
I am using UICollectionViewCompositionalLayout, because I want the cell able to adjust its own height to accommodate its content. (https://stackoverflow.com/a/51231881/72437)
I am using UIStackView in the cell. Reason is that, once I hide one of the UITextViews in the cell, I do not want the hidden UITextView to still occupy the space. Using UIStackView can avoid me from dealing with zero height constraint.
I am using performBatchUpdates and layoutIfNeeded to achieve the animation, based on https://stackoverflow.com/a/69043389/72437
Here's the final outcome - https://www.youtube.com/watch?v=2uggmpk0tJc
As you can see, the overall effect isn't really smooth, espcially when I toggle in between "Color" and "Print PDF", which are having larger content height.
This is what happen when I tap on the cell
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
var indexPaths = [IndexPath]()
for i in (0..<isExpanded.count) {
if isExpanded[i] != false {
isExpanded[i] = false
indexPaths.append(IndexPath(item: i, section: 0))
}
}
if isExpanded[indexPath.item] != true {
isExpanded[indexPath.item] = true
indexPaths.append(IndexPath(item: indexPath.item, section: 0))
}
collectionView.performBatchUpdates({}) { _ in
collectionView.reloadItems(at: indexPaths)
collectionView.layoutIfNeeded()
}
}
Do you have any idea, what else thing I can try out, so that the animation will look smoother? This is the complete code example for demonstration
https://github.com/yccheok/shop-dialog/tree/1a2c06b40327f7a4d6f744f1c3a05a38aa513556
Thank you!
You can get close to your goal by changing didSelectItemAt to this:
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
for i in (0..<isExpanded.count) {
if i == indexPath.item {
// toggle selected row
isExpanded[i].toggle()
} else {
// set all other rows to false
isExpanded[i] = false
}
if let c = collectionView.cellForItem(at: IndexPath(item: i, section: 0)) as? CollectionViewCell {
c._description.isHidden = !isExpanded[i]
}
}
collectionView.performBatchUpdates(nil, completion: nil)
}
The default animation / compression of elements when showing and hiding elements in stack views is not always acceptable though. If you want to try to refine it, take a look at this discussion:
Change default StackView animation
I make a custom calendar using UICollectionView. From the calendar when I selected some dates and move forward to next month, then back again to the previous month, the selected item is deselected. Maybe it happens for reusable cell. How can I solve this problem.
For better understand what I want:
From September I select 4,5 then move to August/July/November (In this month maybe select some other dates or not)
Then return to September. In September I want to showed 4,5 as selected
I tried this using didSelectItemAt indexPath, but when return back to the September the selected item is deselected
public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if let cell = collectionView.cellForItem(at: indexPath) as? CalendarDateRangePickerCell {
if cell.isSelected == true {
cell.backgroundColor = .blue
cell.label.textColor = .white
cell.isUserInteractionEnabled = true
}
selectedDate = cell.date!
}
}
First create an array of selected Cells. If you are using a model to set data to cell, you can create an array of selected models. or you can create an array of selected rows.
Let's say you are using a model.
var selectedDates: [DateModel] = []
Then
public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if let cell = collectionView.cellForItem(at: indexPath) as? CalendarDateRangePickerCell {
selectedDate = cell.date!
if !selectedDates.contains(dataSourceModel[indexPath.row]) {
selectedDates.append(dataSourceModel[indexPath.row])
}
}
}
then in your cellForItem
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if selectedDates.contains(dataSourceModel[indexPath.row]) {
cell.isSelected = true
}
}
Also make sure you remove you model when unSelected
func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
if selectedDates.contains(dataSourceModel[indexPath.row]) {
selectedDates.remove(dataSourceModel[indexPath.row])
}
}
*may contain some syntax error, but you can follow this path to get where you want.
Cells in UITableView and UICollectionView are reused when you scroll, that is why you should store which days selected in another place. Then, in cellForItem you should set isSelected.
Have you tried setting clearsSelectionOnViewWillAppear to false in viewDidLoad()?
A Boolean value indicating if the controller clears the selection when the collection view appears.
The default value of this property is true. When true, the collection view controller clears the collection view’s current selection when it receives a viewWillAppear(_:) message. Setting this property to false preserves the selection.
Source: Apple Developer Documentation
I am trying to recreate the iOS Gallery App, where the user has two collection view controllers, the first one showing all the available images and the second one is displayed when the user taps in an image (a cell) and that same image is shown in another collection view controller with the following function:
DispatchQueue.main.async {
self.collectionView?.scrollToItem(at: self.customIndexPath, at: UICollectionView.ScrollPosition.centeredHorizontally, animated: false)
}
}
where the custom indexPath is set when the user selects an item in the previous collection view controller.
The problem is, in the second collection view controller (Where the image is displayed individually), I don't know how to make it so when the user swipes right or left, the next/previous image is shown and centred, just as if the above function has been called on that image.
I've tried to call the above func in the following code
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
let newIndexPath = IndexPath(item: indexPath.item, section: 0)
print("Item at \(indexPath.item)")
self.collectionView?.scrollToItem(at: newIndexPath, at: UICollectionView.ScrollPosition.centeredHorizontally, animated: true)
}
But its called too soon, the image is replaced by the next image as soon as the user starts going forward or backwards the collection view, I would prefer if the next image is displayed and centred when the user swiped or at least moved horizontally and more than half of the previous image isn't shown and more than half of the next image is shown, the user releases and automatically the next image is centred
import UIKit
class ViewController: UIViewController {
#IBOutlet weak var collectionView: UICollectionView!
var customIndexPath = IndexPath()
let images:[UIImage] = [
UIImage(named: "image_1")!,
UIImage(named: "image_2")!,
UIImage(named: "image_3")!,
UIImage(named: "image_4")!,
UIImage(named: "image_5")!,
UIImage(named: "image_6")!,
UIImage(named: "image_7")!,
UIImage(named: "image_8")!,
]
override func viewDidLoad() {
super.viewDidLoad()
collectionView.delegate = self
// Do any additional setup after loading the view, typically from a nib.
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
print(customIndexPath)
DispatchQueue.main.async {
self.collectionView?.scrollToItem(at: self.customIndexPath, at: UICollectionView.ScrollPosition.centeredHorizontally, animated: false)
}
}
}
extension ViewController: UICollectionViewDataSource, UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return images.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! CollectionViewCell
cell.userImageView.image = images[indexPath.item]
return cell
}
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
let newIndexPath = IndexPath(item: indexPath.item, section: 0)
print("Item at \(indexPath.item)")
self.collectionView?.scrollToItem(at: newIndexPath, at: UICollectionView.ScrollPosition.centeredHorizontally, animated: true)
}
}
The result should be very similar to the iOS Gallery App in a very basic level, when the user swipes, the next image must be shown and centred, not floating around like it is now or showed partially, also if the user swipes slowly and the next cell (next image) is not shown by at least half of its content, then the collection view must display and centre the previous image, just like the iOS Gallery App
My project until now can be found in the following link:
https://github.com/francisc112/Gallery-Test
Thanks in Advance, if there is any tutorial where I can learn to do this, please point it out.
i just tried solving this problem using a bit different approach. Im only using the scrollViewWillEndDragging and scrollViewDidEndDragging methods. Also im using a previousCollectionViewContentOffsetX property which is used to tell if we should scroll to the next or prior cell when the dragging end in case the user make a fast scroll (which is a PhotosApp behaviour).
Finally i've just set a fast deceleration rate to emulate the pagination feature:
collectionView.decelerationRate = .fast
You can see this solution in the pull request i've just made in your repo.
Another simpler solution is to set collectionView's min spacing for cells and lines to zero and enable the collectionView's pagination. We lose the margins between photos shown by the PhotosApp but avoid touching the scrollview delegates. I can make a pull request with this solution if you want.
Let's say I have a UICollectionView with a UICollectionViewFlowLayout, and my items are different sizes. So I've implemented collectionView(_:layout:sizeForItemAt:).
Now let's say I permit the user to rearrange items (collectionView(_:canMoveItemAt:)).
Here's the problem. As a cell is being dragged and other cells are moving out of its way, collectionView(_:layout:sizeForItemAt:) is called repeatedly. But it's evidently called for the wrong index paths: a cell is sized with the index path for the place it has been visually moved to. Therefore it adopts the wrong size during the drag as it shuttles into a different position.
Once the drag is over and collectionView(_:moveItemAt:to:) is called, and I update the data model and reload the data, all the cells assume their correct size. The problem occurs only during the drag.
We clearly are not being given enough information in collectionView(_:layout:sizeForItemAt:) to know what answer to return while the drag is going on. Or maybe I should say, we're being asked for the size for the wrong index path.
My question is: what on earth are people doing about this?
The trick is to implement
override func collectionView(_ collectionView: UICollectionView,
targetIndexPathForMoveFromItemAt orig: IndexPath,
toProposedIndexPath prop: IndexPath) -> IndexPath {
During a drag, that method is called repeatedly, but there comes a moment where a cell crosses another and cells are shoved out of the way to compensate. At that moment, orig and prop have different values. So at that moment you need to revise all your sizes in accordance with how the cells have moved.
To do that, you need to simulate in your rearrangement of sizes what the interface is doing as the cells move around. The runtime gives you no help with this!
Here's a simple example. Presume that the user can move a cell only within the same section. And presume that our data model looks like this, with each Item remembering its own size once collectionView(_:layout:sizeForItemAt:) has initially calculated it:
struct Item {
var size : CGSize
// other stuff
}
struct Section {
var itemData : [Item]
// other stuff
}
var sections : [Section]!
Here's how sizeForItemAt: memoizes the calculated sizes into the model:
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize {
let memosize = self.sections[indexPath.section].itemData[indexPath.row].size
if memosize != .zero {
return memosize
}
// no memoized size; calculate it now
// ... not shown ...
self.sections[indexPath.section].itemData[indexPath.row].size = sz // memoize
return sz
}
Then as we hear that the user has dragged in a way that makes the cells shift, we read in all the size values for this section, perform the same remove-and-insert that the interface has done, and put the rearranged size values back into the model:
override func collectionView(_ collectionView: UICollectionView,
targetIndexPathForMoveFromItemAt orig: IndexPath, toProposedIndexPath
prop: IndexPath) -> IndexPath {
if orig.section != prop.section {
return orig
}
if orig.item == prop.item {
return prop
}
// they are different, we're crossing a boundary - shift size values!
var sizes = self.sections[orig.section].rowData.map{$0.size}
let size = sizes.remove(at: orig.item)
sizes.insert(size, at:prop.item)
for (ix,size) in sizes.enumerated() {
self.sections[orig.section].rowData[ix].size = size
}
return prop
}
The result is that collectionView(_:layout:sizeForItemAt:) now gives the right result during the drag.
The extra piece of the puzzle is that when the drag starts you need to save off all the original sizes, and when the drag ends you need to restore them all, so that when the drag ends the result will be correct as well.
While the accepted answer is pretty clever (props to you Matt 👍), it's actually an unnecessarily elaborate hack. There is a MUCH simpler solution.
The key is to:
Store cell sizes within the data itself.
Manipulate or "rearrange" the data at the point when the "moving" cell enters a new indexPath (NOT when the cell finishes moving).
Fetch cell sizes directly from the data (which is now properly arranged).
Here's what this would look like...
// MARK: UICollectionViewDataSource
override func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
// (This method will be empty!)
// As the Docs states: "You must implement this method to support
// the reordering of items within the collection view."
// However, its implementation should be empty because, as explained
// in (2) from above, we do not want to manipulate our data when the
// cell finishes moving, but at the exact moment it enters a new
// indexPath.
}
override func collectionView(_ collectionView: UICollectionView, targetIndexPathForMoveFromItemAt originalIndexPath: IndexPath, toProposedIndexPath proposedIndexPath: IndexPath) -> IndexPath {
// This will be true at the exact moment the "moving" cell enters
// a new indexPath.
if originalIndexPath != proposedIndexPath {
// Here, we rearrange our data to reflect the new position of
// our cells.
let removed = myDataArray.remove(at: originalIndexPath.item)
myDataArray.insert(removed, at: proposedIndexPath.item)
}
return proposedIndexPath
}
// MARK: UICollectionViewDelegateFlowLayout
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
// Finally, we simply fetch cell sizes from the properly arranged
// data.
let myObject = myDataArray[indexPath.item]
return myObject.size
}
Based on Matts answer I have adapted the code to fit for UICollectionViewDiffableDataSource.
Track the indexPath wile moving the cells:
/// Stores remapped indexPaths during reordering of cells
var changedIndexPaths = [IndexPath: IndexPath]()
func collectionView(_ collectionView: UICollectionView,
targetIndexPathForMoveFromItemAt orig: IndexPath,
toProposedIndexPath prop: IndexPath) -> IndexPath {
guard orig.section == prop.section else { return orig }
guard orig.item != prop.item else { return prop }
let currentOrig = changedIndexPaths[orig]
let currentProp = changedIndexPaths[prop]
changedIndexPaths[orig] = currentProp ?? prop
changedIndexPaths[prop] = currentOrig ?? orig
return prop
}
Calculate size of cells:
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
// remap path while moving cells or use indexPath
let usedPath = changedIndexPaths[indexPath] ?? indexPath
guard let data = dataSource.itemIdentifier(for: usedPath) else {
return CGSize()
}
// Calculate your size for usedPath here and return it
// ...
return size
}
Reset the indexPath map (changedIndexPaths) after final movement of cell is finished:
class DataSource: UICollectionViewDiffableDataSource<Int, Data> {
/// Is called after an cell item was successfully moved
var didMoveItemHandler: ((_ sourceIndexPath: IndexPath, _ target: IndexPath) -> Void)?
override func collectionView(_ collectionView: UICollectionView, canMoveItemAt indexPath: IndexPath) -> Bool {
return true
}
override func collectionView(_ collectionView: UICollectionView, moveItemAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
didMoveItemHandler?(sourceIndexPath, destinationIndexPath)
}
}
dataSource.didMoveItemHandler = { [weak self] (source, destination) in
self?.dataController.reorderObject(sourceIndexPath: source, destinationIndexPath: destination)
self?.resetProposedIndexPaths()
}
func resetProposedIndexPaths() {
changedIndexPaths = [IndexPath: IndexPath]() // reset
}
I've been trying to set up a collection view where I have the user submit several strings which I toss in an array and call back through the collection view's cellForItemAt function. However, whenever I add a row to to the top of the collection view, it adds the cell label literally on top of the last cell label so they stack like this. Notice how every new word I add includes all the other previous words in the rendering.
The code I have at cellForItemAt is
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "InterestsCell", for: indexPath) as? InterestsCell {
cell.interestLabel.text = array[indexPath.row]
return cell
} else {
return UICollectionViewCell()
}
}
and the code I have when the add button is pressed is
func addTapped() {
let interest = interestsField.text
array.insert(interest!, at: 0)
interestsField.text = ""
collectionView.reloadData()
}
I'm not sure what's going on. I looked everywhere and tried to use prepareForReuse() but it didn't seem to work. I later tried deleting cells by calling didSelect and the cells would not disappear again. Any help is appreciated!
This is the code I have in my custom collection view cell implementation in the event that this is causing the error
To do this paste these functions in your project
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
return 1
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return 1
}
you can play around with the values :)
This can be easily implemented using UITableView. So try to use UITableView instead of UICollectionView.
It looks like you're adding a new label every time you set the text. If you want to lazily load the UILabel you need to add lazy
lazy var interestLabel: UILabel = {
...
}()
I wouldn't do it this way though, I would create a reference to the label
weak var interestLabel: UILabel!
Then add the label in your setupViews method
func setupViews() {
...
let interestLabel = UILabel()
...
contentView.addSubview(interestLabel)
self.interestLabel = interestLabel
}