Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 46 additions & 28 deletions apps/demo/src/plugin-demos/imagepicker.xml
Original file line number Diff line number Diff line change
@@ -1,34 +1,52 @@
<Page xmlns="http://www.nativescript.org/tns.xsd" navigatingTo="navigatingTo">
<Page xmlns="http://www.nativescript.org/tns.xsd" navigatingTo="navigatingTo" class="page">
<Page.actionBar>
<ActionBar title="Image Picker" />
<ActionBar title="Image Picker" class="action-bar" />
</Page.actionBar>
<GridLayout rows="*, auto, auto">
<ListView visibility="{{ isSingleMode ? 'collapsed' : 'visible' }}" items="{{ imageAssets }}">
<ListView.itemTemplate>
<GridLayout columns="auto, *" class="m-y-10">
<Image width="80" height="80" src="{{ $value.thumbnail ? $value.thumbnail : $value.asset }}" stretch="aspectFill" />
<StackLayout col="1">
<Label class="m-x-10 m-b-10" style="font-weight: bold; font-size: 18;" text="{{ 'Media Type: ' + $value.type }}" />
<Label class="m-x-10 m-b-5 c-gray t-12" text="{{ 'Filename: ' + $value.filename }}" />
<Label class="m-x-10 m-b-5 c-gray t-12" text="{{ 'File Size: ' + $value.filesize }}" />
<Label class="m-x-10 m-b-5 c-gray t-12" text="{{ 'Path: ' + $value.path }}" textWrap="true" />
<Label class="m-x-10 m-b-5 c-gray t-12" text="{{ 'Duration: ' + $value.duration }}" hidden="{{ !$value.duration }}" />
</StackLayout>
<GridLayout rows="*, auto" class="bg-gray-100">

</GridLayout>
</ListView.itemTemplate>
</ListView>
<GridLayout visibility="{{ isSingleMode ? 'visible' : 'collapsed' }}">
<StackLayout visibility="{{ selection ? 'visible' : 'collapsed' }}">
<Image src="{{ selection.thumbnail || selection.asset }}" width="{{ previewSize }}" height="{{ previewSize }}" stretch="aspectFit" />
<Label class="m-x-10 m-b-10" style="font-weight: bold; font-size: 18;" text="{{ 'Media Type: ' + selection.type }}" />
<Label class="m-x-10 m-b-5 c-gray t-12" text="{{ 'Filename: ' + selection.filename }}" />
<Label class="m-x-10 m-b-5 c-gray t-12" text="{{ 'File Size: ' + selection.filesize }}" />
<Label class="m-x-10 m-b-5 c-gray t-12" text="{{ 'Path: ' + selection.path }}" textWrap="true" />
<Label class="m-x-10 m-b-5 c-gray t-12" text="{{ 'Duration: ' + selection.duration }}" hidden="{{ !selection.duration }}" />
<!-- Progress and results scroll together -->
<ScrollView row="0">
<StackLayout class="pt-4">

<!-- Progress: text, bar and every tick received so far -->
<StackLayout class="mx-4 mb-3 p-4 rounded-2xl bg-white" visibility="{{ progressLog ? 'visible' : 'collapsed' }}">
<Label class="text-base font-semibold text-gray-800 mb-2" text="{{ progressText || 'Done' }}" />
<Progress value="{{ progressValue }}" maxValue="100" height="8" class="mb-3" color="#3b82f6" backgroundColor="#e5e7eb" />
<Label class="text-xs uppercase text-gray-400 mb-1" text="onProgress ticks" />
<Label class="text-sm text-gray-600" text="{{ progressLog }}" textWrap="true" />
</StackLayout>

<!-- Multiple selection -->
<Repeater items="{{ imageAssets }}" visibility="{{ isSingleMode ? 'collapsed' : 'visible' }}">
<Repeater.itemTemplate>
<GridLayout columns="auto, *" class="mx-4 mb-3 p-3 rounded-2xl bg-white">
<StackLayout width="72" height="72" class="rounded-xl bg-gray-200" clipToBounds="true">
<Image width="72" height="72" src="{{ thumbnail ? thumbnail : asset }}" stretch="aspectFill" />
</StackLayout>
<StackLayout col="1" class="ml-3" verticalAlignment="center">
<Label class="text-base font-semibold text-gray-800" text="{{ filename }}" />
<Label class="text-xs text-gray-500 mt-1" text="{{ type + ' · ' + filesize + ' bytes' + (duration ? ' · ' + duration + 's' : '') }}" />
<Label class="text-xs text-gray-400 mt-1" text="{{ shortPath }}" textWrap="true" />
</StackLayout>
</GridLayout>
</Repeater.itemTemplate>
</Repeater>

<!-- Single selection -->
<StackLayout class="mx-4 mb-3 p-3 rounded-2xl bg-white" visibility="{{ isSingleMode &amp;&amp; selection ? 'visible' : 'collapsed' }}">
<!-- Bindings run whenever selection changes, including to null, so each one guards -->
<Image src="{{ selection ? (selection.thumbnail || selection.asset) : null }}" height="{{ previewSize }}" class="rounded-xl" stretch="aspectFill" />
<Label class="text-base font-semibold text-gray-800 mt-3" text="{{ selection ? selection.filename : '' }}" />
<Label class="text-xs text-gray-500 mt-1" text="{{ selection ? selection.type + ' · ' + selection.filesize + ' bytes' + (selection.duration ? ' · ' + selection.duration + 's' : '') : '' }}" />
<Label class="text-xs text-gray-400 mt-1" text="{{ selection ? selection.shortPath : '' }}" textWrap="true" />
</StackLayout>
</StackLayout>
</GridLayout>
<Button row="1" text="Pick Single" tap="{{ onSelectSingleTap }}" horizontalAlignment="center" marginBottom="10" />
<Button row="2" text="Pick Multiple" tap="{{ onSelectMultipleTap }}" horizontalAlignment="center" marginBottom="10" />
</ScrollView>

<StackLayout row="1" class="p-4">
<Button text="Pick Single" tap="{{ onSelectSingleTap }}" class="bg-blue-500 rounded-full text-white p-4 mb-3" />
<Button text="Pick Multiple" tap="{{ onSelectMultipleTap }}" class="bg-blue-500 rounded-full text-white p-4 mb-3" />
<Button text="Clear" tap="{{ onClearTap }}" class="bg-gray-200 rounded-full text-gray-700 p-4" visibility="{{ hasResults ? 'visible' : 'collapsed' }}" />
</StackLayout>
</GridLayout>
</Page>
53 changes: 46 additions & 7 deletions packages/imagepicker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

Imagepicker plugin supporting both single and multiple selection.

- Plugin supports **iOS8+** and uses [QBImagePicker](https://github.com/questbeat/QBImagePicker) cocoapod.
- Plugin supports **iOS 14+** and uses the system [PHPickerViewController](https://developer.apple.com/documentation/photokit/phpickerviewcontroller) (the modern Photos picker with search and albums). No CocoaPods dependency is required.
- For **Android** it uses [Intents](https://developer.android.com/reference/android/content/Intent) to open the stock images or file pickers. For Android 6 (API 23) and above, the permissions to read file storage should be explicitly required.

## Installation
Expand All @@ -28,6 +28,11 @@ Install the plugin by running the following command in the root directory of you
```cli
npm install @nativescript/imagepicker
```
**Note: Version 5.1 changes on iOS:**
* The picker is now the system `PHPickerViewController`. It runs out of process, so it can be presented without photo-library permission; calling `authorize()` first is still recommended so that selections resolve to their `PHAsset` (see [iOS required permissions](#ios-required-permissions)).
* `minimumNumberOfSelection`, `showsNumberOfSelectedAssets`, `prompt`, `numberOfColumnsInPortrait` and `numberOfColumnsInLandscape` are accepted but have no effect, because the system picker owns its own UI.
* Requires iOS 14 or later.

**Note: Version 3.1 contains breaking changes:**
* New behavior on iOS when the user selects `Limit AccessLim..` detailed in [iOS Limited permission](#ios-limited-permission).

Expand Down Expand Up @@ -82,7 +87,9 @@ For phones running < Android 13, this `use_photo_picker` option has no effect.

### iOS required permissions

Using the plugin on iOS requires the `NSPhotoLibraryUsageDescription` permission. Modify the `app/App_Resources/iOS/Info.plist` file to add it as follows:
The system picker itself needs no permission. `authorize()` requests photo-library access so that picked items resolve to their `PHAsset` (giving you `asset`, `filesize`, `duration` and `thumbnail` straight from the library). If access is not granted, `present()` still works: the picker hands over a copy of each selected file, which the plugin stores in the app's temporary folder and exposes through `path` and `asset`.

Calling `authorize()` requires the `NSPhotoLibraryUsageDescription` permission. Modify the `app/App_Resources/iOS/Info.plist` file to add it as follows:

```xml
<key>NSPhotoLibraryUsageDescription</key>
Expand All @@ -96,6 +103,8 @@ Apple introduced the `PHAuthorizationStatusLimited` permission status with iOS 1

In this case `authorise()` will return an `AuthorizationResult` where `authorized` will be `true` and the `details` will contain `'limited'`.

With limited access the system picker still lets the user browse their whole library. Items inside the limited selection resolve to their `PHAsset`; any other item falls back to a copy of the file, exactly as when access was not granted. A single `present()` call can therefore return a mix of both.

Every time the app is launched anew, and the authorize method is called, if the current permission is `limited` the user will be prompted to update the image selection.

To prevent this prompt, add the following values to your `App_Resources/iOS/Info.plist`:
Expand Down Expand Up @@ -159,7 +168,18 @@ imagePickerObj
});
```

On iOS you may also skip `authorize()` altogether: `present()` shows the system picker without any permission and every selection comes back as a file copy (with `asset`, `path`, `filename`, `filesize`, `type`, `duration` and `thumbnail` still populated).

<!--tabs: TS -->
```ts
if (isIOS) {
const selection = await imagePickerObj.present(); // rejects with Error('Canceled') if dismissed
}
```

### Demo
A short recording of the demo app on an iPhone 15 Pro Max with photo access set to None. The PHPicker still opens, the picks come back as file copies, and the progress callback fires for each item: [imagepicker-ios-phpicker.mov](images/imagepicker-ios-phpicker.mov)

You can play with the plugin on StackBlitz at any of the following links:

- [NativeScript TypeScript](https://stackblitz.com/edit/nativescript-stackblitz-templates-2pv6zn?file=app/main-page.xml)
Expand Down Expand Up @@ -187,19 +207,38 @@ An object passed to the `create` method to specify the characteristics of a medi
| Option | Type | Default |Description
|:---------------------------|:-------- |:---------|:-------
| `mode` | `string` | `multiple` | The mode of the imagepicker. Possible values are `single` for single selection and `multiple` for multiple selection. |
| `minimumNumberOfSelection` | `number` | `0` | _Optional_: (`iOS-only`) The minumum number of selected assets. |
| `minimumNumberOfSelection` | `number` | `0` | _Optional_: (`iOS-only`) Deprecated: ignored by the system picker. |
| `maximumNumberOfSelection` | `number` | `0` | _Optional_: (`iOS-only`, `Android-Photo Picker-Only`) The maximum number of selected assets. |
| `showsNumberOfSelectedAssets` | `boolean` | `true` | _Optional_: (`iOS-only`) Display the number of selected assets. |
| `prompt` | `string` | `undefined` | _Optional_: (`iOS-only`) Display prompt text when selecting assets. |
| `numberOfColumnsInPortrait` | `number` | `4` | _Optional_: (`iOS-only`) Sets the number of columns in Portrait orientation |
| `numberOfColumnsInLandscape` | `number` | `7` | _Optional_: (`iOS-only`) Sets the number of columns in Landscape orientation. |
| `showsNumberOfSelectedAssets` | `boolean` | `true` | _Optional_: (`iOS-only`) Deprecated: ignored by the system picker. |
| `prompt` | `string` | `undefined` | _Optional_: (`iOS-only`) Deprecated: ignored by the system picker. |
| `numberOfColumnsInPortrait` | `number` | `4` | _Optional_: (`iOS-only`) Deprecated: ignored by the system picker. |
| `numberOfColumnsInLandscape` | `number` | `7` | _Optional_: (`iOS-only`) Deprecated: ignored by the system picker. |
| `mediaType` | [ImagePickerMediaType](#imagepickermediatype) | `Any` |_Optional_: The type of media asset to pick whether to pick Image/Video/Any type of assets. |
| `copyToAppFolder` | `string` | `undefined` | _Optional_: If passed, a new folder will be created in your applications folder and the asset will be copied there. |
| `renameFileTo` | `string` | `undefined` | _Optional_: If passed, the copied file will be named what you choose. If you select multiple, -index will be appended. |
| `onProgress` | `(progress: ImagePickerProgress) => void` | `undefined` | _Optional_: Called while the selected items are resolved. See [Progress](#progress). |
| `showAdvanced ` | `boolean` | `false` | _Optional_:(`Android-only`) Show internal and removable storage options on Android (**WARNING**: [not supported officially](https://issuetracker.google.com/issues/72053350)). |
| `android` | `{read_external_storage: string;}`| _Optional_: (`Android-only`) Provides a reason for permission request to access external storage on API level above 23.


### Progress

Pass `onProgress` to be told how far along each selected item is while `present()` resolves. Each call carries the item's zero-based `index`, the `total` number of items and a `fraction` from 0 to 1 that never goes backwards. Every item ends with a `fraction` of 1.

<!-- tabs: TS -->
```ts
let imagePickerObj: ImagePicker = imagePickerPlugin.create({
mode: "multiple",
onProgress: ({ index, total, fraction }) => {
progressBar.value = Math.round(fraction * 100);
label.text = `Loading ${index + 1} of ${total}`;
},
});
```

- **iOS** streams the download progress of items that live in iCloud (through `PHPickerViewController`'s item provider, or PhotoKit when the app has library access). Items already on the device usually go straight to 1.
- **Android** has no download progress to report, so it emits a single `fraction: 1` per item once that item is ready. This still lets you show an "n of total" counter.

### ImagePickerMediaType

The type of media assets to be selected.
Expand Down
44 changes: 39 additions & 5 deletions packages/imagepicker/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,27 @@ export interface ImagePickerSelection {
thumbnail?: ImageSource;
}

/**
* Progress of loading one picked item, reported through Options.onProgress.
*/
export interface ImagePickerProgress {
/**
* Zero-based position of the item in the selection.
*/
index: number;

/**
* Number of items in the selection.
*/
total: number;

/**
* Completion of this item from 0 to 1. On iOS this follows the iCloud
* download of the item; on Android only the final 1 is reported.
*/
fraction: number;
}

/**
* Provide options for the image picker.
*/
Expand All @@ -59,7 +80,8 @@ export interface Options {
mode?: string;

/**
* Set the minumum number of selected assets in iOS
* Set the minumum number of selected assets in iOS.
* @deprecated Ignored since 5.1: the system PHPickerViewController owns its own UI.
*/
minimumNumberOfSelection?: number;

Expand All @@ -69,22 +91,26 @@ export interface Options {
maximumNumberOfSelection?: number;

/**
* Display the number of selected assets in iOS
* Display the number of selected assets in iOS.
* @deprecated Ignored since 5.1: the system PHPickerViewController owns its own UI.
*/
showsNumberOfSelectedAssets?: boolean;

/**
* Display prompt text when selecting assets in iOS
* Display prompt text when selecting assets in iOS.
* @deprecated Ignored since 5.1: the system PHPickerViewController owns its own UI.
*/
prompt?: string;

/**
* Set the number of columns in Portrait in iOS
* Set the number of columns in Portrait in iOS.
* @deprecated Ignored since 5.1: the system PHPickerViewController owns its own UI.
*/
numberOfColumnsInPortrait?: number;

/**
* Set the number of columns in Landscape in iOS
* Set the number of columns in Landscape in iOS.
* @deprecated Ignored since 5.1: the system PHPickerViewController owns its own UI.
*/
numberOfColumnsInLandscape?: number;

Expand Down Expand Up @@ -114,6 +140,14 @@ export interface Options {
*/
renameFileTo?: string;

/**
* Called while the selected items are being resolved, once or more per item.
* iOS reports download progress for items that live in iCloud; every item
* ends with a fraction of 1. Android has no download progress to report and
* only emits the final 1 per item.
*/
onProgress?: (progress: ImagePickerProgress) => void;

/**
* Show internal and removable storage options on Android.
* Not supported officially, see https://issuetracker.google.com/issues/72053350 |
Expand Down
Binary file not shown.
Loading