How to create NSPasteboardWriting for Drag and Drop in NSCollectionView - swift

I have a one-section collection view and would like to implement Drag and Drop to allow reordering of the items. The CollectionViewItem has several textviews showing properties form my Parameter objects. Reading the doc I need to implement the NSCollectionView delegate:
func collectionView(_ collectionView: NSCollectionView, pasteboardWriterForItemAt indexPath: IndexPath) -> NSPasteboardWriting? {
let parameter = parameterForIndexPath(indexPath: indexPath)
return parameter // throws an error "Cannot convert return expression of type 'Parameter' to return type 'NSPasteboardWriting?'"
}
I have not found any information understandable for me describing the nature of the NSPasteboardWriting object. So, I have no idea how to proceed...
What is the NSPasteboardWriting object and what do I need to write in the pasteboard?
Thanks!

Disclaimer: I have struggled to find anything out there explaining this in a way that made sense to me, especially for Swift, and have had to piece the following together with a great deal of difficulty. If you know better, please tell me and I will correct it!
The "pasteboardwriter" methods (such as the one in your question) must return something identifiable for the item about to be dragged, that can be written to a pasteboard. The drag and drop methods then pass around this pasteboard item.
Most examples I've seen simply use a string representation of the object. You need this so that in the acceptDrop method you can get your hands back on the originating object (the item being dragged). Then you can re-order that item's position, or whatever action you need to take with it.
Drag and drop involves four principal steps. I'm currently doing this with a sourcelist view, so I will use that example instead of your collection view.
in viewDidLoad() register the sourcelist view to accept dropped objects. Do this by telling it which pasteboard type(s) it should accept.
// Register for the dropped object types we can accept.
sourceList.register(forDraggedTypes: [REORDER_SOURCELIST_PASTEBOARD_TYPE])
Here I'm using a custom type, REORDER_SOURCELIST_PASTEBOARD_TYPE that I define as a constant like so:
`let REORDER_SOURCELIST_PASTEBOARD_TYPE = "com.yourdomain.sourcelist.item"`
...where the value is something unique to your app ie yourdomain should be changed to something specific to your app eg com.myapp.sourcelist.item.
I define this outside any class (so it can be accessed from several classes) like so:
import Cocoa
let REORDER_SOURCELIST_PASTEBOARD_TYPE = "com.yourdomain.sourcelist.item"`
class Something {
// ...etc...
implement the view's pasteboardWriterForItem method. This varies slightly depending on the view you're using (i.e. sourcelist, collection view or whatever). For a sourcelist it looks like this:
// Return a pasteboard writer if this outlineview's item should be able to
// drag-and-drop.
func outlineView(_ outlineView: NSOutlineView, pasteboardWriterForItem item: Any) -> NSPasteboardWriting? {
let pbItem = NSPasteboardItem()
// See if the item is of the draggable kind. If so, set the pasteboard item.
if let draggableThing = ((item as? NSTreeNode)?.representedObject) as? DraggableThing {
pbItem.setString(draggableThing.uuid, forType: REORDER_SOURCELIST_PASTEBOARD_TYPE)
return pbItem;
}
return nil
}
The most notable part of that is draggableThing.uuid which is simply a string that can uniquely identify the dragged object via its pasteboard.
Figure out if your dragged item(s) can be dropped on the proposed item at the index given, and if so, return the kind of drop that should be.
func outlineView(_ outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: Any?, proposedChildIndex index: Int) -> NSDragOperation {
// Get the pasteboard types that this dragging item can offer. If none
// then bail out.
guard let draggingTypes = info.draggingPasteboard().types else {
return []
}
if draggingTypes.contains(REORDER_SOURCELIST_PASTEBOARD_TYPE) {
if index >= 0 && item != nil {
return .move
}
}
return []
}
Process the drop event. Do things such as moving the dragged item(s) to their new position in the data model and reload the view, or move the rows in the view.
func outlineView(_ outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: Any?, childIndex index: Int) -> Bool {
let pasteboard = info.draggingPasteboard()
let uuid = pasteboard.string(forType: REORDER_SOURCELIST_PASTEBOARD_TYPE)
// Go through each of the tree nodes, to see if this uuid is one of them.
var sourceNode: NSTreeNode?
if let item = item as? NSTreeNode, item.children != nil {
for node in item.children! {
if let collection = node.representedObject as? Collection {
if collection.uuid == uuid {
sourceNode = node
}
}
}
}
if sourceNode == nil {
return false
}
// Reorder the items.
let indexArr: [Int] = [1, index]
let toIndexPath = NSIndexPath(indexes: indexArr, length: 2)
treeController.move(sourceNode!, to: toIndexPath as IndexPath)
return true
}
Aside: The Cocoa mandate that we use pasteboard items for drag and drop seems very unnecessary to me --- why it can't simply pass around the originating (i.e. dragged) object I don't know! Obviously some drags originate outside the application, but for those that originate inside it, surely passing the object around would save all the hoop-jumping with the pasteboard.

The NSPasteboardWriting protocol provides methods that NSPasteboard (well, technically anyone, I guess) can use to generate different representations of an object for transferring around pasteboards, which is an older Apple concept that is used for copy/paste (hence Pasteboard) and, apparently, drag and drop in some cases.
It seems that, basically, a custom implementation of the protocol needs to implement methods that:
tell what UTI types (Apple's way of identifying file types [JPEG, GIF, TXT, DOCX, etc], similar to MIME-types—and that's a fun Google search 😬) your type can be transformed into
writeableTypes(for:) & writingOptions(forType:pasteboard:) to a lesser extent
provide a representation of your class for each of the UTI types you claimed to support
pasteboardPropertyList(forType:)
The other answer provides a straightforward implementation of this protocol for use within a single app.
But practically?
The Cocoa framework classes NSString, NSAttributedString, NSURL, NSColor, NSSound, NSImage, and NSPasteboardItem implement this protocol.
So if you've got a draggable item that can be completely represented as a URL (or String, or Color, or Sound, or Image, etc), just take the URL you have and cast it to NSPasteboardWriting?:
func collectionView(_ collectionView: NSCollectionView, pasteboardWriterForItemAt indexPath: IndexPath) -> NSPasteboardWriting? {
let url: URL? = someCodeToGetTheURL(for: indexPath) // or NSURL
return url as NSPasteboardWriting?
}
This is not helpful if you have a complicated type, but I hope it's helpful if you have a collection view of images or some other basic item.

Related

Swift MVVM embedded network fetching with image URL

I'm building a simple app with swift by using MVVM binding.
What my app does is simply fetching data from a url and get a json response, and show the info on a table view.
Each cell contains a title, a subtitle, and an image.
However, the image is showing as a string in the json response. So I will need an extra network fetch to get the image for each cell after the 1st network call.
"articles": [
{
"title": "Wall Street tumbles with Nasdaq leading declines Reuters",
"description": "Wall Street's main indexes tumbled on Monday with Nasdaq leading the declines as technology stocks dropped on expectations of a sooner-than-expected rate hike that pushed U.S. Treasury yields to fresh two-year highs.",
"url": "https://www.reuters.com/markets/europe/wall-street-tumbles-with-nasdaq-leading-declines-2022-01-10/",
"urlToImage": "https://www.reuters.com/resizer/2cEiuwViTo_kOe7eWg4Igm8pm_Q=/1200x628/smart/filters:quality(80)/cloudfront-us-east-2.images.arcpublishing.com/reuters/POAM3MQFAJJX3MRXQW772WYKCA.jpg",
}]
My question is, how should I modify my code to make the tableCellView more "pure"? Currently it's calling network fetch to get the image. Where should I move that image fetching part from the tableviewcell config function to?
Should I change my Model to to contains the UIImage but NOT the string?
My Model:
struct Articles: Codable {
let articles: [Article]
}
struct Article: Codable {
let title: String
let description: String?
let urlToImage: String?
}
My ViewModel:
struct ViewMode {
var articles: Observable<[Article]> = Observable([])
}
Main functions in my ViewController:
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = self
tableView.delegate = self
viewModel.articles.bind { [weak self] _ in
DispatchQueue.main.async {
self?.tableView.reloadData()
}
}
fetchArticlesFromLocal(fileNmae: "response")
}
func fetchArticlesFromLocal(fileNmae: String) {
networkManager.fetchLocalJson(name: fileNmae) { [weak self] result in
switch result {
case.success(let data):
guard let data = data else {return}
do {
let articles = try JSONDecoder().decode(Articles.self, from: data)
self.viewModel.articles.value = articles.articles.compactMap({
Article(title: $0.title, description: $0.description, urlToImage: $0.urlToImage)
})
} catch {
print(error.localizedDescription)
}
case .failure(let error):
print(error.localizedDescription)
}
}
}
}
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.articles.value?.count ?? 0
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if let cell = tableView.dequeueReusableCell(withIdentifier: ImageTableViewCell.cellID, for: indexPath) as? ImageTableViewCell {
if let articles = viewModel.articles.value {
cell.config(with: articles[indexPath.row])
}
}
return UITableViewCell()
}
}
My tableCellView:
func config(with article: Article) {
titleView.text = article.title
descriptionView.text = article.description
networkManager.fetchImage(url: article.urlToImage) { [weak self] result in
switch result {
case .success(let image):
DispatchQueue.main.async {
self?.iconView.image = image
}
case .failure(let error):
return
}
}
}
My binding:
class Observable<T> {
var value: T? {
didSet {
listener?(value)
}
}
typealias Listener = ((T?) -> Void)
var listener: Listener?
init(_ value: T?) {
self.value = value
}
func bind(_ listener: #escaping Listener) {
self.listener = listener
listener(value)
}
}
First of all, you say you use MVVM but I see some issues in implementation: you are passing a Model (Article instance) to the cell, but it should be a ViewModel (ArticleViewModel instance, but you didn't create that struct) since views should not have a direct reference to models in this architecture.
You also seem to add async/networking code inside the custom cell, which also violates the architecture and separation of concerns (even in MVC, that code should not be there, since Network Requests are part of the Service Layer and not the UI layer). The cell/view should know how to configure itself: 1. when image is available and 2. when image is not available... (you call reload on the cell from VC whenever the image becomes available, for instance)
Regarding your question, a better place to add the image downloading code would be cellForRowAt method. ViewModels should not be tied to UIKit generally speaking, so having a URL or String property for the image is fine.
To make the UI display smoothly on scrolling, you probably want to cancel the request: 1. when the image is prepared for reuse aka thrown in the reuse pool or 2. when it disappears from the visible area of the screen.
Here is a good article that uses approach #1: https://www.donnywals.com/efficiently-loading-images-in-table-views-and-collection-views/
Note that it caches the images in memory, but you may also want to cache them on disk.
View Controller is generally also considered to be more of a View, so technically we are still mixing Networking with UI. Perhaps it's better to use repository pattern either directly as a dependency of the View Controller when using MVC or a dependency of VC's View Model. View Model should not be aware if the Repository uses web requests to get the images or has them cached in memory or on disk, so use a protocol and Dependency Injection for that (also View Model may have to calculate the size/height of the cell based on the image size, before the cell is actually configured with that image).
Using this approach would work, but you will again violating some principles, and it's all your choice. Some people say it's ok to have UIKit/UIImage references inside view models (https://lickability.com/blog/our-view-on-view-models/, not sure if their argument should be considered valid though...).
Otherwise, you'll have to use some kind of auxiliary class/object to pass the image urls and do the networking part and keep track of what was already downloaded, what requests should be cancelled etc.
Remember, you don't have to follow MVC or MVVM strictly. Some object has to download those images anyway, and that object ideally should not be none of the view, view model or the data model. Name it as you want, ImageLoadingCoordinator, ImageRepository etc. and make sure it downloads asynchronously and has callbacks and gives the possibility to cancel requests and cache images. Generally speaking, try not to give any object to much responsibility or mix networking code with view code.

How to avoid force casting (as!) in Swift

extension ActionSheetViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return sheetActions.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: TableCellIds.ActionSheet.actionSheetTableCellIdentifier, for: indexPath) as! ActionsSheetCell
cell.actionCellLabel.text = "My cell content goes here"
return cell
}
}
Above code gives me 'Force Cast Violation: Force casts should be avoided. (force_cast)' error. How can I avoid it?
Some force-casts are unavoidable, especially when interacting with Objective C, which has a much more dynamic/loose type system.
In some cases like this, a force-cast would be self-explanatory. If it crashes, clearly you're either:
getting back nil (meaning there's no view with that reuse identifier),
or you're getting back the wrong type (meaning the cell exists, but you reconfigured its type).
In either case your app is critically mis-configured, and there's no much graceful recovery you can do besides fixing the bug in the first place.
For this particular context, I use a helper extension like this (it's for AppKit, but it's easy enough to adapt). It checks for the two conditions above, and renders more helpful error messages.
public extension NSTableView {
/// A helper function to help with type-casting the result of `makeView(wihtIdentifier:owner:)`
/// - Parameters:
/// - id: The `id` as you would pass to `makeView(wihtIdentifier:owner:)`
/// - owner: The `owner` as you would pass to `makeView(wihtIdentifier:owner:)`
/// - ofType: The type to which to cast the result of `makeView(wihtIdentifier:owner:)`
/// - Returns: The resulting view, casted to a `T`. It's not an optional, since that type error wouldn't really be recoverable
/// at runtime, anyway.
func makeView<T>(
withIdentifier id: NSUserInterfaceItemIdentifier,
owner: Any?,
ofType: T.Type
) -> T {
guard let view = self.makeView(withIdentifier: id, owner: owner) else {
fatalError("This \(type(of: self)) didn't have a column with identifier \"\(id.rawValue)\"")
}
guard let castedView = view as? T else {
fatalError("""
Found a view for identifier \"\(id.rawValue)\",
but it had type: \(type(of: view))
and not the expected type: \(T.self)
""")
}
return castedView
}
}
Honestly, after I got experienced enough with the NSTableView APIs, investigating these issues became second nature, and I don't find this extension as useful. Still, it could save some debugging and frustration for devs who are new the platform.
The force cast is actually correct in this situation.
The point here is that you really don't want to proceed if you can't do the cast, because you must return a real cell and if it's the wrong class, the app is broken and you have no cell, so crashing is fine.
But the linter doesn't realize that. The usual way to get this past the linter is to do a guard let with as?, along with a fatalError in the else. That has the same effect, and the linter will buy into it.
I really like the approach suggested by Alexander at https://stackoverflow.com/a/67222587/341994 - here's an iOS modification of it:
extension UITableView {
func dequeue<T:UITableViewCell>(withIdentifier id:String, for ip: IndexPath) -> T {
guard let cell = self.dequeueReusableCell(withIdentifier: id, for: ip) as? T else {
fatalError("could not cast cell")
}
return cell
}
}
So now you can say e.g.:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell : MyTableViewCell = tableView.dequeue(withIdentifier: "cell", for: indexPath)
// ...
return cell
}
And everyone is happy including the linter. No forced unwraps anywhere and the cast is performed automatically thanks to the generic and the explicit type declaration.
As others have said, a force cast is appropriate in this case, because if it fails, it means you have a critical error in your source code.
To make SwiftLint accept the cast, you can surround the statement with comments as described in this issue in the SwiftLint repo:
// swiftlint:disable force_cast
let cell = tableView.dequeueReusableCell(withIdentifier: TableCellIds.ActionSheet.actionSheetTableCellIdentifier, for: indexPath) as! ActionsSheetCell
// swiftlint:enable force_cast
The right thing to do is: remove force_cast from swift lint’s configuration file. And be professional: only write force casts where you mean “unwrap or fatal error”. Having to “get around the linter” is a pointless waste of developer time.

How can I implement didSelectRowAt method without relying on indexPath

So I'm very new to the Swift programming language and iOS development in general and I'm trying to implement a simple tableview for navigation.
I didn't like the idea of relying on the indexPath parameter to determine which action to perform in my code as changing the order of the cells will need me to go back and refactor the method too. so I was looking at implementing a multi-dimensional array to store my different items for the table.
This works absolutely fine for the cell contents but I'm running into issues when trying to implement the didSelectRowAt method.
Note this is all within a UIViewController class with the UITableViewDataSource delegate.
private let options: [[( title: String, action: (() -> ())? )]] = [
[
("title" , action)
]
]
func action() {
//perform logic here
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let action = self.options[indexPath.section][indexPath.row].action else { return }
action()
}
However I am recieving a build error:
Cannot convert value of type '("MyViewControllerName") -> () -> ()' to expected element type '(() -> ())?'
What am I missing here? Is this even the best way to implement this method?
The issue is that:
1 - action is a method not a simple function.
2 - you are using action before your view controller is actually instantiated (since it's in the init of a stored property).
So there is one simple way to solve your issue:
Replace private let options
by
private lazy var options
for instance.
That way the options will be initialised after your ViewController so the action method will be available as a function.
Overall, it's not a bad idea to try to tie the action to your data.

NSPasteBoard for Drag and Drop

I try to move an NSCollectionViewItem within the same NSCollectionView to allow reordering of the items. The collection view item is a custom item representing multiple labels with strings from a custom object Parameter:
class Parameter: NSObject {
var parameterName: String
var parameterNominal: Double
var parameterUpperTolerance: Double
var parameterLowerTolerance: Double
var parameterDistribution: String
var parameterNotes: String
var parameterDataArray: [Double]
// MARK: - Initialization Methods
...
}
Now, do I have to write the whole item/object into the 'NSPasteboard' to use drag and drop within the NSCollectionView correctly?
Most of the drag 'n drop examples works with writing strings to pasteboard using ....register(forDraggedTypes: [NSPasteboardTypeString]).
I currently write one of the items string into the pasteboard and the drag 'n drop starts fine:
func collectionView(_ collectionView: NSCollectionView, pasteboardWriterForItemAt indexPath: IndexPath) -> NSPasteboardWriting? {
let pb = NSPasteboardItem()
pb.setString(ParameterList.sharedInstance.parameters[indexPath.item].parameterName, forType: NSPasteboardTypeString)
return pb
}
... but it looks strange to write only part of the object to pasteboard when moving of the complete object is requested, right?
How can I actually write my parameter object to the pasteboard (if needed)?
What is happening behind the scene with the data in the pasteboard when using drag and drop?
Thanks a lot!
I haven't used the pasteboard with collection views, but I have with outline views. If you are just reordering internally dragged items you don't really need to use the paste functionality fully - it is designed to allow generic types to be passed around across apps.
You could just do the equivalent of this:
func outlineView(_ outlineView: NSOutlineView, pasteboardWriterForItem item: Any) -> NSPasteboardWriting? {
pasteboardItem.setString("Something to pass - not used", forType:MyConstants.pasteboardType)
return pasteboardItem
}
You'll need to have some way of internally caching the dragged items in the function above. Then, in the accept drop you can just check for your pasteboard type and use your cache.

Using swift to populate NSTableView rows with a NSPopupButtonCell

I have been trying to change one of the cells in an NSTableView to a pull-down menu, but have been unsuccessful. I read the Apple developer documentation, but it doesn't give an example of how to use NSPopupButtonCell in a NSTableView. I searched forums, including here, and only found one somewhat relevant example, except that it was in objective-c, so it doesn't work for my swift app. Code for the table is here:
extension DeviceListViewController:NSTableViewDataSource, NSTableViewDelegate{
// get the number of rows for the table
func numberOfRows(in tableView: NSTableView) -> Int {
return homedevices.count
}
// use the data in the homedevices array to populate the table cells
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView?{
let result = tableView.make(withIdentifier: (tableColumn?.identifier)!, owner: self) as! NSTableCellView
if tableColumn?.identifier == "ID" {
result.textField?.stringValue = homedevices[row].id
} else if tableColumn?.identifier == "Name" {
result.textField?.stringValue = homedevices[row].name
result.imageView?.image = homedevices[row].image
} else if tableColumn?.identifier == "Type" {
result.textField?.stringValue = homedevices[row].type
} else if tableColumn?.identifier == "Button" {
result.textField?.integerValue = homedevices[row].button
}
return result
}
// facilitates data sorting for the table columns
func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) {
let dataArrayMutable = NSMutableArray(array: homedevices)
dataArrayMutable.sort(using: tableView.sortDescriptors)
homedevices = dataArrayMutable as! [HomeDevice]
tableView.reloadData()
}
}
I really just want to be able to allow pull-down selection to change the button assigned to a particular homedevice (a simple integer), instead of having to type a number into the textfield to edit this value. Unfortuantely, when I add the popupbuttoncell to my table in IB, all of the views for my table cells are removed. So I may need to create the table differently. But most of the things I have read about and tried have caused runtime errors or display an empty table.
EDIT:
Day 3:
Today I have been reading here: https://developer.apple.com/library/content/documentation/Cocoa/Conceptual/TableView/PopulatingViewTablesWithBindings/PopulatingView-TablesWithBindings.html
and many other places too, but I don't have rep to post any more links.
I have added a NSPopupButton in IB, but am not sure how to set the value. I tried result.objectValue = homedevices[row].button, but that does not work. I suppose that I need an array controller object. So then I tried creating an outlet for the object in my DeviceListViewController like #IBOutlet var buttonArrayController: NSArrayController! I guess that I now need to somehow find a way to connect the array controller to my homedevices array.
so I looked at example code here:
https://github.com/blishen/TableViewPopup
This is in objective-C, which is not a language I am using, but maybe if I keep looking at it at various times over the course of the week, I might figure out how to make a pull-down menu.
So I am continuing to work at this, with no solution currently.
This issue is solved, thanks to #vadian.
The button is inserted as NSPopUpButton object, rather than a NSPopUpButtonCell.
Then the cell gets its own custom class, which I called ButtonCellView as a subclass of NSTableCellView.
Then the created subclass can receive an outlet from the NSPopUpButton to the custom subclass. I can give this a selectedItem variable and create the menu here.
Then in the table view delegate, when making the table, I can just set the selectedItem of my ButtonCellView object to the value from my data array.
It works great!