Skip to content

Commit 8e13e98

Browse files
committed
feat(dialog) - Support picker mode for open dialog (#3030)
For iOS and Android, there are 2 distinct file picker dialogs, one for picking media and one for picking documents. Previously, the filters provided by the user would determing which picker would be displayed. However, this has led to undefined behavior when no filter was provided. To resolve this, we now provide a PickerMode (meant for iOS and eventually Android) to explicitly define which picker we want to display. Eventually, we may need to provide more explicit ways of filtering by MIME type or having specific modes for ImagePicker or VideoPicker for ease of use on mobile platforms. But for now, this is an initial implementation that allows specifying which UI would be preferable for a file picker on mobile platforms.
1 parent 6b5b105 commit 8e13e98

File tree

6 files changed

+115
-39
lines changed

6 files changed

+115
-39
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"dialog": minor
3+
"dialog-js": minor
4+
---
5+
6+
Add `pickerMode` option to file picker (currently only used on iOS)

examples/api/src/views/Dialog.svelte

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
let filter = null;
99
let multiple = false;
1010
let directory = false;
11+
let pickerMode = "";
1112
1213
function arrayBufferToBase64(buffer, callback) {
1314
var blob = new Blob([buffer], {
@@ -65,6 +66,7 @@
6566
: [],
6667
multiple,
6768
directory,
69+
pickerMode: pickerMode === "" ? undefined : pickerMode,
6870
})
6971
.then(function (res) {
7072
if (Array.isArray(res)) {
@@ -94,10 +96,10 @@
9496
onMessage(res);
9597
}
9698
})
97-
.catch(onMessage(res));
99+
.catch(e => onMessage(e));
98100
}
99101
})
100-
.catch(onMessage);
102+
.catch(e => onMessage(e));
101103
}
102104
103105
function saveDialog() {
@@ -112,7 +114,7 @@
112114
},
113115
]
114116
: [],
115-
})
117+
})
116118
.then(onMessage)
117119
.catch(onMessage);
118120
}
@@ -142,6 +144,14 @@
142144
<input type="checkbox" id="dialog-directory" bind:checked={directory} />
143145
<label for="dialog-directory">Directory</label>
144146
</div>
147+
<div>
148+
<label for="dialog-picker-mode">Picker Mode:</label>
149+
<select id="dialog-picker-mode" bind:value={pickerMode}>
150+
<option value="">None</option>
151+
<option value="media">Media</option>
152+
<option value="document">Document</option>
153+
</select>
154+
</div>
145155
<br />
146156

147157
<div class="flex flex-wrap flex-col md:flex-row gap-2 children:flex-shrink-0">
@@ -156,4 +166,4 @@
156166
<button class="btn" id="message-dialog" on:click={msg}>Message</button>
157167
<button class="btn" id="message-dialog" on:click={msgCustom}>Message (custom)</button>
158168

159-
</div>
169+
</div>

plugins/dialog/guest-js/index.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,14 @@ interface DialogFilter {
3030
interface OpenDialogOptions {
3131
/** The title of the dialog window (desktop only). */
3232
title?: string
33-
/** The filters of the dialog. */
33+
/**
34+
* The filters of the dialog.
35+
* The behavior of filters on mobile platforms (iOS and Android) is different from desktop.
36+
* On Android, we are not able to filter by extension, only by MIME type.
37+
* On iOS, we are able to filter by extension in the document picker, but not in the media picker.
38+
* In practice, if an extension for a video or image is included in the filters (such as `png` or `mp4`),
39+
* the media picker will be displayed instead of the document picker (regardless of the {@linkcode pickerMode} value).
40+
*/
3441
filters?: DialogFilter[]
3542
/**
3643
* Initial directory or file path.
@@ -52,6 +59,25 @@ interface OpenDialogOptions {
5259
recursive?: boolean
5360
/** Whether to allow creating directories in the dialog. Enabled by default. **macOS Only** */
5461
canCreateDirectories?: boolean
62+
/**
63+
* The preferred mode of the dialog.
64+
* This is meant for mobile platforms (iOS and Android) which have distinct file and media pickers.
65+
* On desktop, this option is ignored.
66+
* If not provided, the dialog will automatically choose the best mode based on the MIME types of the filters.
67+
*
68+
* As a note, this is only fully implemented on iOS for the time being.
69+
* Android does have a specific media picker, but it requires one of two of the below:
70+
* 1) Defining exactly one mime type of media (for example, `image/*` or `video/*`) for use by the ACTION_GET_CONTENT intent.
71+
* If both of these MIME types are provided to the filter, the Document picker will be displayed.
72+
* 2) Using the registerForActivityResult API to register a result callback for the media picker as opposed to the ACTION_GET_CONTENT intent.
73+
* This is the recommended way to implement the media picker on Android, as the ACTION_GET_CONTENT intent is currently marked as deprecated.
74+
* However, using registerForActivityResult requires being called from within the actual Activity object (it is a protected method), for which Tauri currently
75+
* does not have a hook.
76+
* As a result, we currently only support the document picker on Android until either:
77+
* 1) We allow for explicitly defining a VideoPicker or a ImagePicker on Android.
78+
* 2) A hook is provided to allow for calling registerForActivityResult from within the Android Activity object.
79+
*/
80+
pickerMode?: PickerMode
5581
}
5682

5783
/**
@@ -77,6 +103,8 @@ interface SaveDialogOptions {
77103
canCreateDirectories?: boolean
78104
}
79105

106+
export type PickerMode = 'document' | 'media'
107+
80108
/**
81109
* Default buttons for a message dialog.
82110
*

plugins/dialog/ios/Sources/DialogPlugin.swift

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,19 @@ struct FilePickerOptions: Decodable {
3232
var multiple: Bool?
3333
var filters: [Filter]?
3434
var defaultPath: String?
35+
var pickerMode: PickerMode?
3536
}
3637

3738
struct SaveFileDialogOptions: Decodable {
3839
var fileName: String?
3940
var defaultPath: String?
4041
}
4142

43+
enum PickerMode: String, Decodable {
44+
case document
45+
case media
46+
}
47+
4248
class DialogPlugin: Plugin {
4349

4450
var filePickerController: FilePickerController!
@@ -54,20 +60,15 @@ class DialogPlugin: Plugin {
5460

5561
let parsedTypes = parseFiltersOption(args.filters ?? [])
5662

57-
var isMedia = !parsedTypes.isEmpty
58-
var uniqueMimeType: Bool? = nil
59-
var mimeKind: String? = nil
63+
var filtersIncludeImage = false
64+
var filtersIncludeVideo = false
6065
if !parsedTypes.isEmpty {
61-
uniqueMimeType = true
62-
for mime in parsedTypes {
63-
let kind = mime.components(separatedBy: "/")[0]
64-
if kind != "image" && kind != "video" {
65-
isMedia = false
66-
}
67-
if mimeKind == nil {
68-
mimeKind = kind
69-
} else if mimeKind != kind {
70-
uniqueMimeType = false
66+
for type in parsedTypes {
67+
let kind = type.preferredMIMEType?.components(separatedBy: "/")[0]
68+
if kind == "image" {
69+
filtersIncludeImage = true
70+
} else if kind == "video" {
71+
filtersIncludeVideo = true
7172
}
7273
}
7374
}
@@ -83,18 +84,20 @@ class DialogPlugin: Plugin {
8384
}
8485
}
8586

86-
if uniqueMimeType == true || isMedia {
87+
// If the picker mode is media, we always want to show the media picker regardless of what's in the filters.
88+
// Otherwise, if the filters include image or video, we want to show the media picker.
89+
if args.pickerMode == .media || (filtersIncludeImage || filtersIncludeVideo) {
8790
DispatchQueue.main.async {
8891
if #available(iOS 14, *) {
8992
var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
9093
configuration.selectionLimit = (args.multiple ?? false) ? 0 : 1
9194

92-
if uniqueMimeType == true {
93-
if mimeKind == "image" {
94-
configuration.filter = .images
95-
} else if mimeKind == "video" {
96-
configuration.filter = .videos
97-
}
95+
// If the filters include image or video, use the appropriate filter.
96+
// If both are true, don't define a filter, which means we will display all media.
97+
if filtersIncludeImage && !filtersIncludeVideo {
98+
configuration.filter = .images
99+
} else if filtersIncludeVideo && !filtersIncludeImage {
100+
configuration.filter = .videos
98101
}
99102

100103
let picker = PHPickerViewController(configuration: configuration)
@@ -105,22 +108,24 @@ class DialogPlugin: Plugin {
105108
let picker = UIImagePickerController()
106109
picker.delegate = self.filePickerController
107110

108-
if uniqueMimeType == true && mimeKind == "image" {
111+
if filtersIncludeImage {
109112
picker.sourceType = .photoLibrary
110113
}
111114

112-
picker.sourceType = .photoLibrary
113115
picker.modalPresentationStyle = .fullScreen
114116
self.presentViewController(picker)
115117
}
116118
}
117119
} else {
118-
let documentTypes = parsedTypes.isEmpty ? ["public.data"] : parsedTypes
119120
DispatchQueue.main.async {
120-
let picker = UIDocumentPickerViewController(documentTypes: documentTypes, in: .import)
121+
// The UTType.item is the catch-all, allowing for any file type to be selected.
122+
let contentTypes = parsedTypes.isEmpty ? [UTType.item] : parsedTypes
123+
let picker: UIDocumentPickerViewController = UIDocumentPickerViewController(forOpeningContentTypes: contentTypes, asCopy: true)
124+
121125
if let defaultPath = args.defaultPath {
122126
picker.directoryURL = URL(string: defaultPath)
123127
}
128+
124129
picker.delegate = self.filePickerController
125130
picker.allowsMultipleSelection = args.multiple ?? false
126131
picker.modalPresentationStyle = .fullScreen
@@ -173,19 +178,16 @@ class DialogPlugin: Plugin {
173178
self.manager.viewController?.present(viewControllerToPresent, animated: true, completion: nil)
174179
}
175180

176-
private func parseFiltersOption(_ filters: [Filter]) -> [String] {
177-
var parsedTypes: [String] = []
181+
private func parseFiltersOption(_ filters: [Filter]) -> [UTType] {
182+
var parsedTypes: [UTType] = []
178183
for filter in filters {
179184
for ext in filter.extensions ?? [] {
180-
guard
181-
let utType: String = UTTypeCreatePreferredIdentifierForTag(
182-
kUTTagClassMIMEType, ext as CFString, nil)?.takeRetainedValue() as String?
183-
else {
184-
continue
185+
if let utType = UTType(filenameExtension: ext) {
186+
parsedTypes.append(utType)
185187
}
186-
parsedTypes.append(utType)
187188
}
188189
}
190+
189191
return parsedTypes
190192
}
191193

plugins/dialog/src/commands.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use tauri_plugin_fs::FsExt;
1010

1111
use crate::{
1212
Dialog, FileDialogBuilder, FilePath, MessageDialogBuilder, MessageDialogButtons,
13-
MessageDialogKind, MessageDialogResult, Result, CANCEL, NO, OK, YES,
13+
MessageDialogKind, MessageDialogResult, PickerMode, Result, CANCEL, NO, OK, YES,
1414
};
1515

1616
#[derive(Serialize)]
@@ -56,6 +56,13 @@ pub struct OpenDialogOptions {
5656
recursive: bool,
5757
/// Whether to allow creating directories in the dialog **macOS Only**
5858
can_create_directories: Option<bool>,
59+
/// The preferred mode of the dialog.
60+
/// This is meant for mobile platforms (iOS and Android) which have distinct file and media pickers.
61+
/// On desktop, this option is ignored.
62+
/// If not provided, the dialog will automatically choose the best mode based on the MIME types of the filters.
63+
#[serde(default)]
64+
#[cfg_attr(mobile, allow(dead_code))]
65+
picker_mode: Option<PickerMode>,
5966
}
6067

6168
/// The options for the save dialog API.
@@ -127,6 +134,9 @@ pub(crate) async fn open<R: Runtime>(
127134
if let Some(can) = options.can_create_directories {
128135
dialog_builder = dialog_builder.set_can_create_directories(can);
129136
}
137+
if let Some(picker_mode) = options.picker_mode {
138+
dialog_builder = dialog_builder.set_picker_mode(picker_mode);
139+
}
130140
for filter in options.filters {
131141
let extensions: Vec<&str> = filter.extensions.iter().map(|s| &**s).collect();
132142
dialog_builder = dialog_builder.add_filter(filter.name, &extensions);

plugins/dialog/src/lib.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png"
1010
)]
1111

12-
use serde::Serialize;
12+
use serde::{Deserialize, Serialize};
1313
use tauri::{
1414
plugin::{Builder, TauriPlugin},
1515
Manager, Runtime,
@@ -44,6 +44,13 @@ pub use desktop::Dialog;
4444
#[cfg(mobile)]
4545
pub use mobile::Dialog;
4646

47+
#[derive(Debug, Serialize, Deserialize, Clone)]
48+
#[serde(rename_all = "lowercase")]
49+
pub enum PickerMode {
50+
Document,
51+
Media,
52+
}
53+
4754
pub(crate) const OK: &str = "Ok";
4855
pub(crate) const CANCEL: &str = "Cancel";
4956
pub(crate) const YES: &str = "Yes";
@@ -369,6 +376,7 @@ pub struct FileDialogBuilder<R: Runtime> {
369376
pub(crate) file_name: Option<String>,
370377
pub(crate) title: Option<String>,
371378
pub(crate) can_create_directories: Option<bool>,
379+
pub(crate) picker_mode: Option<PickerMode>,
372380
#[cfg(desktop)]
373381
pub(crate) parent: Option<crate::desktop::WindowHandle>,
374382
}
@@ -380,6 +388,7 @@ pub(crate) struct FileDialogPayload<'a> {
380388
file_name: &'a Option<String>,
381389
filters: &'a Vec<Filter>,
382390
multiple: bool,
391+
picker_mode: &'a Option<PickerMode>,
383392
}
384393

385394
// raw window handle :(
@@ -395,6 +404,7 @@ impl<R: Runtime> FileDialogBuilder<R> {
395404
file_name: None,
396405
title: None,
397406
can_create_directories: None,
407+
picker_mode: None,
398408
#[cfg(desktop)]
399409
parent: None,
400410
}
@@ -406,6 +416,7 @@ impl<R: Runtime> FileDialogBuilder<R> {
406416
file_name: &self.file_name,
407417
filters: &self.filters,
408418
multiple,
419+
picker_mode: &self.picker_mode,
409420
}
410421
}
411422

@@ -466,6 +477,15 @@ impl<R: Runtime> FileDialogBuilder<R> {
466477
self
467478
}
468479

480+
/// Set the picker mode of the dialog.
481+
/// This is meant for mobile platforms (iOS and Android) which have distinct file and media pickers.
482+
/// On desktop, this option is ignored.
483+
/// If not provided, the dialog will automatically choose the best mode based on the MIME types of the filters.
484+
pub fn set_picker_mode(mut self, mode: PickerMode) -> Self {
485+
self.picker_mode.replace(mode);
486+
self
487+
}
488+
469489
/// Shows the dialog to select a single file.
470490
/// This is not a blocking operation,
471491
/// and should be used when running on the main thread to avoid deadlocks with the event loop.

0 commit comments

Comments
 (0)