- Add accessible move actions to any array of items in a SwiftUI List or ForEach.
- Make drag-and-drop operations easier for custom types in iOS 14 and 15 using
Providable
- Make drag-to-create-a-new-window operations easier in iPadOS using
UserActivityProvidable
The Example
folder has an app that demonstrates the features of this package and how to set up Drag and Drop for Custom Types.
- In Xcode go to
File -> Add Packages
- Paste in the repo's url:
https://github.com/ryanlintott/ILikeToMoveIt
and select by version. - Import the package using
import ILikeToMoveIt
This package is compatible with iOS 14+ but the accessibility move feature only works for iOS 15+.
If you like this package, buy me a coffee to say thanks!
Or you can buy a t-shirt with the iLikeToMoveIt logo
*iOS 15+
Two modifiers are required to enable accessible move actions. One for each item and one for the list itself.
List {
ForEach(items) { item in
Text(item.name)
.accessibilityMoveable(item)
}
}
.accessibilityMoveableList($items, label: \.name)
Adding this modifier will add accessibility actions to move the item up, down, to the top of the list and to the bottom. If you want to customize these actions you can supply your own array.
Example: If you have a short list and only want up and down.
.accessibilityMoveable(item, actions: [.up, .down])
Example: If you have a long list and want options to move items more than one step at a time.
.accessibilityMoveable(item, actions: [.up, .down, .up(5), .down(5), .toTop, .toBottom])
When the user triggers an accessibility action the following results are reported back via a UIAccessibility announcement:
- "moved up", "moved down", or "not moved"
- "by [number of spaces]" if moved by more than one space.
- "above [item label]" if moved down and "below [item label]" if moved up. Only if a label keypath is was provided.
- "At top" or "At bottom" if at the top or bottom of the list.
This modifier applies the changes from the move actions to the list and adjusts the accessibility focus to ensure it stays on the correct item.
You pass in a binding to the array of items and an optional label keypath. This label will be read out after moving an item to let the user know what item is directly below after moving up or directly above after moving down.
.accessibilityMoveableList($items, label: \.name)
- Moving the same item again immediately after moving it may cause the accessibility focus to lag and another item will be moved instead.
This protocol allows for easier drag and drop for Codable
objects in iOS 14 and 15
Drag and drop operations were made much easier in iOS 16 by the Transferable
protocol. Older methods use NSItemProvider
and were cumbersome to set up.
Conform your object to Providable
. Add readable and writable types, then add functions to transform your object to and from those types.
extension Bird: Providable {
static let writableTypes: [UTType] = [.bird]
static let readableTypes: [UTType] = [.bird, .plainText]
func data(type: UTType) async throws-> Data? {
switch type {
case .bird:
return try JSONEncoder().encode(self)
default:
return nil
}
}
init?(type: UTType, data: Data) throws {
switch type {
case .bird:
self = try JSONDecoder().decode(Bird.self, from: data)
case .plainText:
let string = String(decoding: data, as: UTF8.self)
self = Bird(name: string)
default:
return nil
}
}
}
You will need to add any custom types to your project. Project > Target > Info > ExportedTypeIdentifiers
Add a drag option to a view like this:
.onDrag { bird.provider }
And a drop option like this:
.onDrop(of: Bird.readableTypes) { providers, location in
providers.loadItems(Bird.self) { bird, error in
if let bird {
birds.append(bird)
}
}
return true
}
And even an insert option like this:
.onInsert(of: Bird.readableTypes) { index, providers in
providers.loadItems(Bird.self) { bird, error in
if let bird {
birds.insert(bird, at: index)
}
}
}
Extension to the Providable
protocol to add easy drag to new window (a feature not supported by Transferable
) on iPadOS 16+
Add your activity type string to plist under NSUserActivityTypes
and then add the same string to the activityType parameter on your codable type.
extension Bird: UserActivityProvidable {
static let activityType = "com.ryanlintott.draganddrop.birdDetail"
}
Use the onContinueUserActivity
overload function that takes a UserActivityProvidable
object to handle what your app does when opened via this activity.
.onContinueUserActivity(Bird.self) { bird in
/// Adjust state based on your object.
}
You can also target a separate WindowGroup for your object. Make sure you still use onContinueUserActivity
in your view to ensure the object gets loaded.
WindowGroup {
BirdDetailView()
}
.handlesExternalEvents(matching: [Bird.activityType])
- Start with a
Codable
object that you want to drag and drop.
struct Bird: Codable {
let name: String
}
- Add your custom object info to your Project
Project > Target > Info > Exported Type Identifiers
- Add your type as an extension to UTType
import UniformTypeIdentifiers
extension UTType {
static let bird = UTType("com.ryanlintott.draganddrop.bird") ?? .data
}
- Add this package to your project and follow instructions to conform your object to
Providable
.
- Conform your object to
Transferable
- Add the
transferRepresetation
property and include aCodableRepresentation
for your custom type along with aDataRepresentation
for any other compatible types.
static var transferRepresentation: some TransferRepresentation {
CodableRepresentation(contentType: .bird)
DataRepresentation(importedContentType: .plainText) { data in
let string = String(decoding: data, as: UTF8.self)
return Bird(name: string)
}
}
Once your type conforms to Transferable
, adding SwiftUI drag and drop modifiers is easy!
Make any view draggable by adding this modifier
.draggable(bird)
Any view can be a drop destination. Add the dropped items using the action, use the location for animation if you like, and use the isTargeted closure to animate the view when droppable content is hovering.
.dropDestination(for: Bird.self) { droppedBirds, location in
birds.append(contentsOf: droppedBirds)
return true
} isTargeted: {
isTargetted = $0
}
When added to ForEach dropped items can be inserted in-between other items.
.dropDestination(for: Bird.self) { droppedBirds, offset in
birds.insert(contentsOf: droppedBirds, at: offset)
}