17.
Adding Photos to Your App
Written by Caroline Begbie
In the previous chapter, you learned how to add stickers to your card. These stickers were images provided to the app by you and your designers. Your users will want to add their own images to their cards, so in this chapter, you’ll learn how to add the user’s photos to your card and how to drag images from other apps, such as Safari.
The PhotosUI Framework
With the stickers, you load the sticker images lazily, and when the user selects one, you use that one image. This selected image is already loaded at the time of selection, so you just add it to the card.
Loading photos is not as simple as loading stickers, because the user’s media library might number in the tens of thousand of assets, and the user might select multiple photos. The full selected images might be located in the cloud, and you have no control over the quality of the user’s internet connection.
Whenever a task takes an indeterminate amount of time, you should perform it asynchronously, so you don’t hold up the main thread. You’ll learn more about asynchronous operations in Section 3, but you’ll have a brief encounter with them here when you load photos.
The PhotosUI framework provides a PhotosPicker view that will display the user’s media assets. The user then selects photos, and each selected item goes into an array. As the item is added to the array, the picker downloads the full photo file on a background thread. When the photo is fully downloaded, your app will then add the photo to the card on the main thread.
The PhotosPicker View
Skills you’ll learn in this section:
PhotosPicker
Instead of using your own modal view, you’ll use PhotosUI’s PhotosPicker.
➤ Open the starter project for this chapter.
Aside from the Stickers folder being located at the same level as the project, the project is the same as the previous chapter’s challenge project.
➤ In the Card Modal Views folder, create a new SwiftUI View file called PhotosModal.swift and import the framework:
import PhotosUI
➤ Replace PhotosModal with:
struct PhotosModal: View {
@Binding var card: Card
// 1
@State private var selectedItems: [PhotosPickerItem] = []
var body: some View {
// 2
PhotosPicker(
// 3
selection: $selectedItems,
// 4
matching: .images) {
// 5
ToolbarButton(modal: .photoModal)
}
}
}
Going through the code:
- Create an array to hold the selected images. The type
PhotosPickerItemdoesn’t contain the actual image data. Instead, it contains only an identifier and the type of content, such asjpeg, that the item supports. - Display the photos picker view.
- As the user taps and selects media assets, the photos picker adds them to
selectedItems. - You can filter the photo library in various ways, such as screenshots or videos. For Cards, you filter images. You can see the other available filters here.
-
PhotosPickerrequires a label to start it, so you include the image and text you already set up inToolbarButton.
Note: Be careful with your file names. If you create a structure called
PhotosPicker, that will override the one used by PhotosUI without any warning. You can still usePhotosUI.PhotosPicker, but you have to specifically referencePhotosUIwhen you do.
➤ Change the preview to:
#Preview {
PhotosModal(card: .constant(Card()))
}
In Live Preview, you’ll see the button with the label you provided:
➤ Tap the button to see how the system photos picker works. You can select multiple photos and also select from your photo albums. You can also show larger versions of all the images you’ve selected.
Adding the Photos Picker to Your App
➤ Open CardToolbar.swift, and locate .sheet(item: $currentModal).
This is where you display the modal views when the user taps a button on the bottom toolbar.
➤ Add a new case to switch item:
case .photoModal:
PhotosModal(card: $card)
Here you set up the photo button on the toolbar to display your photos modal view.
➤ Open SingleCardView.swift and pin the preview. In Live Preview, tap the Photos button on the bottom toolbar.
You may have anticipated this. Your PhotosModal view pops up from your button. This view contains the system PhotosPicker view which you defined with its own label. You obviously don’t want to compel your user to press two buttons.
➤ Open BottomToolbar.swift and, in BottomToolbar, locate Button {...} inside ForEach.... Control-Click Button, and select Embed….
➤ Change Container { to:
switch selection {
default:
➤ Add a new case to switch selection before the default case:
case .photoModal:
Button {
} label: {
PhotosModal(card: $card)
}
The resulting view from PhotosModal is the button you supply to PhotosPicker, so this replaces the previous ToolbarButton.
➤ BottomToolbar needs to contain the binding, so add the new property to BottomToolbar:
@Binding var card: Card
➤ Replace BottomToolbar in the preview with:
BottomToolbar(
card: .constant(Card()),
modal: .constant(.stickerModal))
Remember to add the parameters in the order they appear in BottomToolbar.
➤ Open CardToolbar.swift and remove:
case .photoModal:
PhotosModal(card: $card)
BottomToolbar loads the photos view now, so this is no longer needed.
➤ Inside ToolbarItem(placement: .bottomBar) {, locate BottomToolbar(modal: $currentModal). Replace it with:
BottomToolbar(
card: $card,
modal: $currentModal)
You now pass the binding and your app compiles.
➤ Resume Live Preview on SingleCardView, and select the Photos button on the bottom toolbar.
This time you see the system photos picker. When you tap Cancel, the Photos modal disappears. So far, when you select photos and tap Add, nothing happens. The system retains the selection, however, as you’ll see if you return to the photos picker.
The Transferable Protocol
Skills you’ll learn in this section:
Transferable; Uniform Type Identifiers; add photos to Simulator
It’s not only photos that you might want to add to your app. You might want to be able to copy and paste text, or even custom types, such as files created by another app. You’ll also want to share your card with your friends, which means exporting your card from your app. Transferable is a flexible protocol that allows you to describe how to import and export any types.
Some existing data types, such as Data, which is a string of bytes, already conform to Transferable. When you add photos, these will be of type UIImage, which unfortunately does not conform.
You’ll overcome the non-conformance later in the chapter. For the moment, though, to get you quickly adding photos, you’ll transfer the photos as Data.
➤ Open PhotosModal.swift and add this modifier to PhotosPicker:
.onChange(of: selectedItems) { _, items in
for item in items {
print(item)
}
selectedItems = []
}
Whenever selectedItems changes, you’ll print out each element in the array. After you’ve processed each item, clear the array.
➤ In Live Preview, tap the Photos button on the bottom toolbar and select the pink flowers photo and one other. Tap Add.
The details of each selected photo print out in the debug console. The pink flowers photo has two supported content types: public.jpeg and public.heic. The other photo has just one type: public.jpeg.
Uniform Type Identifiers
Uniform Type Identifiers, or UTIs, identify file types. For example, JPG is a standard UTI, with the identifier public.jpeg. It’s a subtype of the base image data type public.image.
public.text encompasses all text data, including public.plainText and public.rtf.
Most apps have associated data types. For example, when you right-click a macOS file and choose Open With, the menu presents you with all the apps associated with that file’s data format. When you right-click a .png file, you might see a list like this:
These are the apps that are able to open .png files.
There are many standard system UTIs which you can find at https://apple.co/3xASdxD.
If you have a custom data format, you can create your own type in a UTType extension:
extension UTType {
static var myType: UTType =
{ UTType(exportedAs: "com.kodeco.myType") }
}
public.data is a base type representing a stream of bytes. Using this type, you can load the photos as a data stream and then convert the data to a UIImage.
Adding Photos to Your App
➤ Still in PhotosModal.swift, in the for loop, replace print(item) with:
item.loadTransferable(type: Data.self) { result in
Task {
// create a UIImage
}
}
You load the item as a Data type. result is of type Result<Success, Failure>. Success contains the image data, and Failure contains a failure value.
For each item, you load the image on a background thread using Task {}.
➤ Replace // create a UIImage with:
switch result {
case .success(let data):
if let data,
let uiImage = UIImage(data: data) {
card.addElement(uiImage: uiImage)
}
case .failure(let failure):
fatalError("Image transfer failed: \(failure)")
}
If the result succeeds, use the data to create a UIImage and add that image to the card’s element array. If the result fails, produce a fatal error.
You add the image to the card on a background thread. However, changing the data pushes a screen refresh which must happen on the main thread. You’ll probably get a compile warning:
You should add the card asynchronously on the main thread.
➤ Change card.addElement(uiImage: uiImage) to:
await MainActor.run {
card.addElement(uiImage: uiImage)
}
The card will now add the element when the photo is loaded and ready.
➤ Live Preview SingleCardView and add some photos to the card.
Adding Photos to Simulator
You can test your app fully in Simulator or on your device. If you want more photos in Simulator than the ones Apple supplies, you can simply drag and drop your photos from Finder into Simulator. Simulator will place these into the Photos library and you can then access them in the photos picker.
Drag and Drop From Other Apps
Skills you’ll learn in this section: Split view; drag and drop; data representation
The photos library is not the only place you can access photos. Modern apps should accept photos and images that you drag from any other app.
First set up Simulator so that you’ll be able to do the drag and drop.
➤ Build and run your app on an iPad simulator and turn the iPad to landscape mode. You can use the icon on the top bar, or use Command-Right Arrow.
➤ Tap the three dots at the top of Simulator’s screen and choose Split View.
➤ Locate the Safari icon and tap it.
Safari will load using half of the iPad screen.
➤ In the Cards app, tap a card. In Safari, Google your favorite animal and tap Images. Long press an image until it gets slightly larger and drag it onto your card.
Cards is not ready to receive a drop yet, so nothing happens. If the drop area were able to receive an item, you would get a plus sign next to the image.
Adding the Dropped Item to Your App
➤ Open CardDetailView.swift and, in body, add this modifier to ZStack:
.dropDestination(for: Data.self) { receivedData, location in
print(location)
for data in receivedData {
if let image = UIImage(data: data) {
card.addElement(uiImage: image)
}
}
return !receivedData.isEmpty
}
Just as you did with your photos, you receive the dragged image or images as an array of data streams. You create a UIImage from the data and add the image to the card’s array of elements. You return whether the operation was successful.
Currently you don’t use location, so any dropped items are added to the center of the card. To calculate the offset for the element’s transform, you’ll need to convert the location point on the card to an offset from the center of the card. This involves knowing the screen size of the card. You’ll revisit this problem in Chapter 20, “Delightful UX — Layout”.
➤ Build and run again, and drag in photos from Safari.
As you drag over the drop area, a plus sign will appear on the drop pile, indicating that the drop destination is allowable for this data type. When you drop the photo, it’s added to the card at the center.
In Simulator, to select multiple images in Safari at the same time, pick up an image and start dragging it. That small drag is important — you won’t be able to multiple select without it. Then hold down Control. Release the click and then Control. A gray dot appears on the image representing your finger on a device. Click other images to add them to the drag pile.
When you’ve collected all the images, drag them to Cards.
Conforming Types to Transferable
When you conform a type to Transferable, you describe the representation of the data. You have full control over what you can transfer and how you transfer it. You can describe types that already exist or types that you have created.
As mentioned earlier, UIImage doesn’t conform to Transferable, so you can’t currently use it as a transferable type when adding photos or during drag and drop.
You could extend UIImage to conform to Transferable, but as you don’t own the type, you don’t know whether Apple will add the conformance themselves later. You should only ever conform types that you own.
Sticking with best practices, you’ll create a custom transfer structure that will conform to Transferable. At the same time, you’ll be able to start transferring text as well as images.
➤ In the Model folder, create a new empty file named CustomTransfer.swift and add this code:
import SwiftUI
// 1
struct CustomTransfer: Transferable {
// 2
var image: UIImage?
var text: String?
// 3
public static var transferRepresentation:
some TransferRepresentation {
// 4
DataRepresentation(importedContentType: .image) { data in
let image = UIImage(data: data) ?? UIImage.error
return CustomTransfer(image: image)
}
// 5
DataRepresentation(importedContentType: .text) { data in
return CustomTransfer(text: "Dragged Text")
}
}
}
This code needs some explanation:
- You create a structure that conforms to
Transferable. - You create two properties, one for text and one for image.
-
transferRepresentationis a required property.TransferRepresentationdescribes how to transfer an item. You can describe how to import and export the item. - When the imported
UTTypeis an image, you’ll import the image as data and construct aUIImagefrom that data. - When the imported
UTTypeis text, you’ll import the text as data and construct aStringfrom that data. It ought to be as easy as converting the data to a string, but Safari hands over a string with attributes. You’ll return to this later.
Updating the Drag and Drop
You can now import both dropped photos and text. Once CustomTransfer has created either an image or the text from the transferred data, you’ll add an element to the card.
➤ First open Card.swift and add this new method:
mutating func addElement(text: TextElement) {
elements.append(text)
}
Just as you did for image elements, you create a text element.
➤ Add this new method to add elements from your custom transfer:
mutating func addElements(from transfer: [CustomTransfer]) {
for element in transfer {
if let text = element.text {
addElement(text: TextElement(text: text))
} else if let image = element.image {
addElement(uiImage: image)
}
}
}
Here you check whether you are transferring text or image data and you create the appropriate card element.
➤ Open CardDetailView.swift and replace the dropDestination modifier with:
.dropDestination(for: CustomTransfer.self) { items, location in
print(location)
Task {
await MainActor.run {
card.addElements(from: items)
}
}
return !items.isEmpty
}
Abstracting out the transfer to a new property makes the code more readable. You can extend CustomTransfer to receive any type of data, and create any type of elements you choose.
➤ Build and run your app and test dragging images and text from Safari.
Selecting text can be a bit fiddly. Long press text to select it, and then long press the selection before dragging it to Cards. Because of the way you defined your data representation, your dropped text will always be “Dragged Text.
Dropping an Attributed String
As you’ve seen, you can define how dragged-in data is processed. Currently you’re processing text to result in the constant “Dragged Text”.
Up to iOS 18.0, you could define your data representation as:
let text = String(decoding: data, as: UTF8.self)
return CustomTransfer(text: text)
UTF8 is the most common Unicode encoding system. This code still works for most apps, such as News. Safari, however, transfers a string with attributes that you can interpret as HTML.
Open CustomTransfer.swift and replace the second DataRepresentation(importedContentType:) with:
DataRepresentation(importedContentType: .text) { data in
let docType = NSAttributedString.DocumentType.html
let encoding = String.Encoding.utf8.rawValue
guard let text = try? NSAttributedString(
data: data,
options: [
.documentType: docType,
.characterEncoding: encoding
],
documentAttributes: nil
) else {
return CustomTransfer(text: nil)
}
return CustomTransfer(text: text.string)
}
Here you treat the data as an HTML attributed string.
➤ Build and run your app and try dragging and dropping text from both Safari. Then replace the Safari app to run News alongside Cards, and drag text and images from there.
Pasting From Another App
Skills you’ll learn in this section: Cut and paste
Once you’ve set up your CustomTransfer, as well as dragging photos from another app, you can instead copy them and paste them on your card.
SwiftUI provides PasteButton for this. It doesn’t allow a lot of customization, and you can’t add it to a Menu, but it is easy to implement.
➤ Open CardToolbar.swift.
This is where you place your toolbar items.
➤ Add a new item inside toolbar(content:):
ToolbarItem(placement: .topBarLeading) {
PasteButton(payloadType: CustomTransfer.self) { items in
Task {
await MainActor.run {
card.addElements(from: items)
}
}
}
}
You’ve now implemented paste in your app. I told you it was easy! PasteButton will be disabled unless it detects a CustomTransfer item. Then when you tap Paste, the items will be added to your card in the same way as the drop.
➤ Build and run the app on an iPad simulator with Safari in split screen and choose a card. Long press an image in Safari, and choose Copy.
The image is now in the pasteboard (also known as clipboard) ready to paste. Copy-and-paste will also work with text.
➤ Tap Paste a few times to add copies of the image to your card.
➤ Add these modifiers to PasteButton(payloadType:):
.labelStyle(.iconOnly)
.buttonBorderShape(.capsule)
This removes the word “Paste” leaving only the icon and gives the button a capsule shape. The paste button is now a little less obtrusive, but the design still doesn’t fit well.
Adding a Pop-up Menu
Skills you’ll learn in this section: Pop-up menu; context menu;
UIPasteBoard; remove from array
As you build up your app, you’ll probably want to add a few extra buttons for operations that don’t really need to be always on screen. You can add a pop-up menu for all these operations. Unfortunately, PasteButton won’t work on this menu, so you’ll use a Button which updates UIKit’s UIPasteboard.
➤ Replace the PasteButton ToolbarItem with:
ToolbarItem(placement: .topBarTrailing) {
menu
}
➤ This toolbar item will be more complicated than the previous one, so add a new property to CardToolbar:
var menu: some View {
// 1
Menu {
Button {
// add action here
} label: {
Label("Paste", systemImage: "doc.on.clipboard")
}
// 2
.disabled(!UIPasteboard.general.hasImages
&& !UIPasteboard.general.hasStrings)
} label: {
Label("Add", systemImage: "ellipsis.circle")
}
}
There are a couple of things to note here:
- You add a
Menuto the top toolbar just to the left of the Done button. AMenuis a list of buttons. For this app, you’ll only have one button, but you can very easily add more under the Paste button. - You only want the paste button to be enabled when there is something to paste, so you check
hasImagesandhasStrings. If both are false, you disable the button.
➤ Build and run the app and tap the ellipsis.
Your paste button shows up on the menu.
➤ Back in CardToolbar.swift, in menu, replace // add action here with:
if UIPasteboard.general.hasImages {
if let images = UIPasteboard.general.images {
for image in images {
card.addElement(uiImage: image)
}
}
} else if UIPasteboard.general.hasStrings {
if let strings = UIPasteboard.general.strings {
for text in strings {
card.addElement(text: TextElement(text: text))
}
}
}
You can check whether the pasteboard contains images or strings. Apple’s documentation states not to test images or strings to see whether they contain data, but to check hasImages and hasStrings.
➤ Build and run your app on iPad with Safari in split screen. Then, try copying and pasting text and images.
When pasting from another app, iOS will ask permission whether to paste.
Note: Apple’s Universal Clipboard is very powerful. For example, if you run Cards on a device, you can select and copy photos in the macOS Photos app and paste them into Cards on the device.
Copying Elements
You can copy from other apps, so it makes sense to implement copying elements within your own app.
You do this with contextMenu(menuItems:) modifiers on card elements. You activate the context menu with a long press, just as you did when you copied from Safari. When you choose Copy from the context menu, the system will add the element — text or image — to the pasteboard. You can then paste the text or image in your app, or even in another app.
➤ In the Single Card Views folder, create a new empty file called ElementContextMenu.swift and add this code:
import SwiftUI
struct ElementContextMenu: ViewModifier {
@Binding var card: Card
@Binding var element: CardElement
func body(content: Content) -> some View {
content
}
}
This context menu will need access to the current card and current element. Creating a view modifier should be familiar to you from Chapter 14, “Gestures”, when you created resizableView().
➤ Add a new modifier to content:
.contextMenu {
Button {
if let element = element as? TextElement {
UIPasteboard.general.string = element.text
} else if let element = element as? ImageElement,
let image = element.uiImage {
UIPasteboard.general.image = image
}
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
}
The context menu will pop up when you perform a long press on a card element. When you tap Copy, the pasteboard will record the text or image element details ready for pasting elsewhere.
Your modifier is ready to use, but, as you did with ResizableView, you should make it easier to use.
➤ Add this to the end of ElementContextMenu.swift.
extension View {
func elementContextMenu(
card: Binding<Card>,
element: Binding<CardElement>
) -> some View {
modifier(ElementContextMenu(
card: card,
element: element))
}
}
This extension to View simply calls your new modifier with a card and an element value.
➤ Open CardDetailView.swift, and, in body, add this code to CardElementView(element: element) as the first modifier:
.elementContextMenu(
card: $card,
element: $element)
You have now added a new context menu to each element that you can access with a long press on that element. You must place the modifier before the following ones so that the context menu appears in the correct place on the screen.
➤ Build and run the app and experiment with copying elements and pasting them in other cards, or even in other apps. Even when copying the element in Simulator, you can paste it into another macOS app.
Deletion
You can easily add elements to your cards by copying and pasting them in, but if you make a mistake, you aren’t able to remove the element. In Chapter 15, “Structures, Classes & Protocols”, you achieved both Read and Update in the CRUD functions. Next, you’ll take on Deletion.
You’ll add an entry to the context menu. When you tap the menu item, your app will remove the selected card element from the card’s array.
➤ Open Card.swift and add this code to Card:
mutating func remove(_ element: CardElement) {
if let index = element.index(in: elements) {
elements.remove(at: index)
}
}
Here you retrieve the index of the card element. You then remove the element from the array using the index.
➤ Open ElementContextMenu.swift and add a new button to the context menu:
Button(role: .destructive) {
card.remove(element)
} label: {
Label("Delete", systemImage: "trash")
}
Your delete button should be highlighted as dangerous, and that’s what the destructive role does for you. The menu item will be in red.
➤ Live Preview SingleCardView, add a photo to the card, and then, long press the photo.
You’ll see the context menu pop up.
➤ Tap Delete to delete the element, or tap away from the menu if you decide not to delete it.
In summary, when you delete the element, you delete it from card.elements. card is bound to cards in the data store, and cards is a published property. When cards changes, all views containing cards will redisplay their content.
Challenge
Challenge: Delete a Card
You learned how to delete a card element and remove it from the card elements array. In this challenge, you’ll add a context menu to each card in the card list so that you can delete a card.
-
In
CardStore, create a similar remove method as the one inCardto remove a card from the cards array. -
In
CardsListView, add a new context menu to a card with a delete option that calls your new method to remove the card.
You’ll find the solution to this challenge in the challenge folder for this chapter.
Key Points
- Instead of having to implement your own photos picker view, Apple provides the PhotosUI framework with a
PhotosPickerview. It’s an easy way to select photos and videos from the photo library. - Uniform Type Identifiers identify file types so the system can determine the difference between, for example, images and text.
- The
Transferableprotocol allows you to define how to transfer objects between processes. You define a customTransferableobject for drag and drop, pasting and sharing. - A
Menuis a list ofButtons. EachButtoncan have arole. By making the roledestructive, the menu item will appear in red. -
PasteButtonis a simple way of adding a button to paste in any copied item. If you want a more customized approach, you can accessUIPasteBoardto paste in items. - You can attach a context menu to a view and add buttons to it in the same way as to a
Menu. You access the context menu by a long press. SwiftUI brings the view to the foreground and darkens the other views. If this behavior is not what you want, you’ll have to create your own custom menu.