I recently built a video gallery for a personal project using SwiftUI on iOS 18 that supports uploading videos, previewing them in a grid, and playing them in a custom video player. The UI mimics the native Photos app. This post shows the full approach end‑to‑end.
Use PhotosPicker to import videos
Save videos to local file storage and track them with SwiftData
Generate thumbnails for fast grids
Build a custom AVPlayer-backed video player with custom controls
And also lets create our sheet view that lets the user add their video.
One little caveat to remember is, sheets are not pushed on the NavigationStack, so if we want a title for our sheet view, we need to give its own NavigationStack.
Now let’s actually get into the code. We’re going to use the PhotoPicker introduced in iOS 16. Add the state variable to keep track of the selected PhotosPickerItem and the PhotoPicker itself in place of our placeholder text.
Tip: If testing on a simulator and your library has no videos, just drag and drop videos from your Mac onto the simulator and it stores them in your library automatically for use.
To break this down, our PhotosPicker returns us a PhotosPickerItem on user selection, and we want to load that item in to store in our local FileStorage asynchronously. Now let’s implement the aforementioned loading.
Our loadSelectedVideo uses loadTransferable to get the Data object of our video, and then we create a name for our video file, save it to our local documents directory, and store the file name and URL at which we saved our file. Storing the file name instead of only the URL is critical as we will find out later. (Hint: URL.documentsDirectory changes every time the simulator is run, so absolute paths change)
Now, looking ahead, in our gallery we want to store potentially 10s to 100s of videos if our app scales. Loading and displaying videos is also costly in terms of resource consumption. Attempting to load/display a mass amount of videos onAppear of a View would be disastrous for the user experience. The solution I came to is to store thumbnails for every video, only display these thumbnails to the user, and on tap of the thumbnail, we load our video in our custom VideoPlayer we create later.
For now, let’s go ahead and modify our code to store thumbnails at the same time we load our video.
1// AddVideoView.swift 2 3@State privatevarthumbnailURL: URL? = nil 4@State privatevarthumbnailFileName: String? = nil 5 6...
7 8privatefuncloadSelectedVideo(from item: PhotosPickerItem) async {
9// Try to download the item10ifletdata = try? await item.loadTransferable(type: Data.self),
11letfileExtension = item.supportedContentTypes.first?.preferredFilenameExtension {
1213// Now that we have the data, we need to save it locally14letvideoName = UUID().uuidString +".\(fileExtension)"1516ifletfileURL = saveDataToDocumentsDir(data, with: videoName) {
17// Success! Lets save our URL and video filename18 videoURL = fileURL
19 videoFileName = videoName
2021// Let's generate our thumbnail while we're here!22letresult = await generateThumbnail(videoURL: fileURL)
23guardletthumbnailURL = result.0, letthumbnailName = result.1else {
24print("Failed to generate thumbnail from video: \(fileURL)")
25return26 }
2728// Save our thumbnail URL and filename29self.thumbnailURL = thumbnailURL
30self.thumbnailFileName = thumbnailName
31 } else {
32print("Failed to save data item to directory.")
33 }
34 } else {
35print("Failed to load media item as data")
36 }
37}
3839privatefuncgenerateThumbnail(videoURL: URL) async -> (URL?, String?) {
40varfinalURL: URL? = nil41letfileName = UUID().uuidString +".jpg"4243// Create a generator based on a video asset44letasset = AVURLAsset(url: videoURL)
45letgenerator = AVAssetImageGenerator(asset: asset)
46 generator.appliesPreferredTrackTransform = true// For default rotation47 videoDuration = try? await asset.load(.duration).seconds // Get duration for fun4849// Change the time if you want your thumbnail to be from a different part 50// of the video51lettime = CMTimeMakeWithSeconds(0.0, preferredTimescale: 500)
5253do {
54// Now we need to store our thumbnail in local storage as well55// In order to use our saveToDocumentsDir method, we need the file in Data format56// To convert a CGImage --> UIImage --> Data57letcgImage = try await generateCGImageAsync(generator: generator, for: time)
58letuiImage = UIImage(cgImage: cgImage)
5960// Now we have a Data object61ifletimageData = uiImage.jpegData(compressionQuality: 0.8) {
626364// Save our thumbnail65ifletsavedURL = saveDataToDocumentsDir(imageData, with: fileName) {
66print("Thumbnail saved at \(savedURL)")
67 finalURL = savedURL
68 } else {
69print("Failed to save thumbnail to directory.")
70 }
71 } else {
72print("Failed to convert UIImage to Data.")
73 }
74 } catch {
75print("Failed to generate thumbnail: \(error.localizedDescription)")
76 }
7778// Return a pair of the URL and name for convenience79return (finalURL, fileName)
80}
8182/* Wrapper around generateCGImageAsync
83 * This converts the callback-based API into async/await using a continuation.
84 * The continuation resumes execution when the image generation completes,
85 * either with a CGImage or an error.
86 */87privatefuncgenerateCGImageAsync(generator: AVAssetImageGenerator, for time: CMTime) async throws -> CGImage {
88try await withCheckedThrowingContinuation { continuation in89 generator.generateCGImageAsynchronously(for: time) { cgImage, _, error in90ifleterror = error {
91 continuation.resume(throwing: error) // Resume with error92 } elseifletcgImage = cgImage {
93 continuation.resume(returning: cgImage) // Resume with the image94 } else {
95 continuation.resume(throwing: NSError(domain: "ThumbnailError", code: -1, userInfo: nil))
96 }
97 }
98 }
99}
Now I just threw out a lot of code so let’s go through it:
Added state variables to store thumbnail filename and
Minimal changes to loadSelectedVideo, just unwrapping the result of our thumbnail generation and storing it in our state
generateThumbnail - Takes the brunt of the work:
Makes a AVURLAsset with our video and then a AVAssetImageGenerator with that asset
We use the generator to get the duration of the video and the thumbnail at time 00:00
Converts our thumbnail of type CGImage to a Data object to save to our documents directory (Have to go CGImage → UIImage → Data unfortunately, if anyone knows a better way please let me know!)
generateCGImageAsync - Now this method is a wrapper around AVAssetImageGenerator.generateCGImageAsynchronously(). This async call returns with a callback. However, here’s the problem. Without this wrapper, we would be calling generateCGImageAsynchronously inside our generateThumbnail async function. Using old callback-based APIs and async/await doesn’t mix well and can cause concurrency issues. So we take advantage of withCheckedThrowingContinuation.
If you would like to read more about continuations, these HackingWithSwift articles (1, 2) helped me out.
Now that we have that all completed, we can finally go ahead and display the selected video (in reality, we only show the user the thumbnail), and on confirmation, we save a Video object into SwiftData.
Displaying Selected Video and Saving with SwiftData
#
Display a preview (thumbnail) and persist a Video record to SwiftData on Save.
1// AddVideoView.swift 2 3enumVideoState {
4case unkown
5case loading
6case loaded
7case failed
8}
9 10@State privatevarvideoState: VideoState = .unkown
11 12...
13 14// New UI for adding Videos that displays the selected Video underneath 15Section(header: Text("Video")) {
16 PhotosPicker("Select Video", selection: $selectedVideo, matching: .videos)
17 .onChange(of: selectedVideo) { _, _in 18ifletselectedVideo {
19 Task {
20 await loadSelectedVideo(from: selectedVideo)
21 }
22 }
23 }
24 25switch videoState {
26case .unkown:
27 EmptyView()
28case .loading:
29 ProgressView()
30case .loaded:
31 VideoItem(thumbnailFileName: thumbnailFileName!, duration: videoDuration ?? 0.0, size: 100)
32case .failed:
33 Text("Import failed")
34 }
35}
36 37...
38 39// Save our Video to SwiftData 40privatefuncsaveVideo() {
41// Logic to save video 42guardletvideoFileName = videoFileName,
43letthumbnailFileName = thumbnailFileName,
44letvideoDuration = videoDuration else { return }
45 46letnewVideo = Video(
47 id: UUID(),
48 thumbnailFileName: thumbnailFileName,
49 videoFileName: videoFileName,
50 duration: videoDuration,
51 date: date
52 )
53 54 modelContext.insert(newVideo) // Insert into SwiftData 55 56do {
57try modelContext.save() // Save changes 58 presentationMode.wrappedValue.dismiss()
59 } catch {
60print("Failed to save video: \(error)")
61 }
62 presentationMode.wrappedValue.dismiss()
63}
64 65...
66 67// *** Added change of videoState so we can display progress to the user 68privatefuncloadSelectedVideo(from item: PhotosPickerItem) async {
69 videoState = .loading
70 71// Try to download the item 72ifletdata = try? await item.loadTransferable(type: Data.self),
73letfileExtension = item.supportedContentTypes.first?.preferredFilenameExtension {
74 75// Now that we have the data, we need to save it locally 76letvideoName = UUID().uuidString +".\(fileExtension)" 77 78ifletfileURL = saveDataToDocumentsDir(data, with: videoName) {
79// Success! Lets save our URL and video fileName 80 videoURL = fileURL
81 videoFileName = videoName
82 83// Let's generate our thumbnail while we're here! 84letresult = await generateThumbnailURL(videoURL: fileURL)
85guardletthumbnailURL = result.0, letthumbnailName = result.1else {
86print("Failed to generate thumbnail from video: \(fileURL)")
87return 88 }
89 90self.thumbnailURL = thumbnailURL
91self.thumbnailFileName = thumbnailName
92 93// Done loading 94 videoState = .loaded
95 } else {
96print("Failed to save data item to directory.")
97 videoState = .failed
98 }
99 } else {
100print("Failed to load media item as data")
101 videoState = .failed
102 }
103}
1// Video.swift 2 3importSwiftUI 4importAVKit 5importSwiftData 6 7/*
8 * Swift Data object for storing our Video that the user chooses
9 */10@Model
11classVideo: Identifiable {
1213varid: UUID
14varthumbnailFileName: String15varvideoFileName: String16varduration: Double17vardate: Date
1819init(id: UUID, thumbnailFileName: String, videoFileName: String, duration: Double, date: Date) {
20self.id = id
21self.thumbnailFileName = thumbnailFileName
22self.videoFileName = videoFileName
23self.duration = duration
24self.date = date
25 }
2627}
2829structVideoItem: View {
3031varthumbnailFileName: String32varduration: Double33varsize: CGFloat = 20034varfontSize: CGFloat = 123536// Recreate full path on demand due to changing documentsDirectory path37varthumbnailURL: URL {
38 URL.documentsDirectory.appendingPathComponent(thumbnailFileName)
39 }
4041varbody: some View {
42 ZStack(alignment: .bottomTrailing) {
4344// Thumbnail45 AsyncImage(url: thumbnailURL) { res in46 res.image?
47 .resizable()
48 .aspectRatio(contentMode: .fill)
49 }
50 .frame(width: size, height: size)
51 .clipped()
52 .overlay(Rectangle().stroke(Color.black, lineWidth: 1))
5354// Duration text on bottom right55 Text("\(formatDuration(duration))")
56 .font(.system(size: fontSize, weight: .heavy, design: .rounded))
57 .foregroundStyle(.white)
58 .padding(5)
59 }
6061 }
6263funcformatDuration(_ durationInSeconds: Double) -> String {
64letformatter = DateComponentsFormatter()
65 formatter.allowedUnits = [.minute, .second]
66 formatter.zeroFormattingBehavior = [.pad] // Ensures double digits for seconds (e.g., 0:09)67 formatter.unitsStyle = .positional // Removes labels, keeps it like 1:346869return formatter.string(from: durationInSeconds) ?? "0:00"70 }
7172}
Another dump of changes:
The first code block consists of a few changes including storing our video state so we can display a progress view when loading, and then the final video when done. Also saveVideo function for saving to SwiftData.
The 2nd code block can be in a new file. We define our SwiftData model and also a little square component to display a thumbnail.
And now we’re done with picking a video, storing it to our file system, and adding it to SwiftData.
And here we are, a custom Video Player. To make it more in line with the Photos app, we could also make a custom Slider to get rid of the thumb, but that’s for another time.
Let’s use this in our Gallery View along with the @Namespace property wrapper to create a zoom transition between the VideoItem and VideoPlayer.
And there we have it, a video gallery with ability to add Videos using PhotosPicker and a custom VideoPlayer. Still working on refining it but it should be good for anyone who wants to use it as a base for their own work. Below is a video of the final product and I’m also linking the GitHub with full source code.
GitHub: https://github.com/DakshinD/VideoGallery
Files:
ContentView.swift - contains the grid
VideoPlayer.swift + VideoPlayerView.swift - Custom video player
Video.swift - contains the VideoItem and SwiftData model