Connect with us

Разработка

Встроенный фото-пикер в Jetpack Compose

Встроенный фото пикер — это не «кастомный интерфейс галереи». Это системный инструмент выбора фотографий, отображаемый внутри вашей иерархии UI, обладающий теми же свойствами безопасности и конфиденциальности, что и классический инструмент выбора.

Опубликовано

/

     
     

Встроенный фото-пикер — это не «кастомный интерфейс галереи». Это системный инструмент выбора фотографий, отображаемый внутри вашей иерархии UI, обладающий теми же свойствами безопасности и конфиденциальности, что и классический инструмент выбора, поскольку система отрисовывает его в выделенном SurfaceView (внутренне подключаемом через SurfaceView.setChildSurfacePackage). Именно это архитектурное решение открывает ключевой сдвиг в продукте: пользователь остается на вашем экране во время просмотра и выбора, а ваше приложение может реагировать на обновления выбора в реальном времени, поскольку ваша активити остается рабочей.

Встроенный пикер в настоящее время поддерживается на устройствах Android 14 (API 34) с расширениями SDK 15+. На устройствах, не соответствующих этому стандарту, Android рекомендует использовать классический инструмент выбора фотографий (включая бекпорт через сервисы Google Play, где это применимо).

Вот минимальный вспомогательный инструмент, который вы можете разместить рядом с точкой входа в ваш пользовательский интерфейс:

import android.os.Build
import android.os.ext.SdkExtensions

fun isEmbeddedPhotoPickerAvailable(): Boolean {
    // Embedded picker requires Android 14+ anyway.
    if (Build.VERSION.SDK_INT < 34) return false

    // SDK Extensions are the actual gate for embedded support.
    return SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 15
}

Интеграция с Compose

Интеграция Jetpack для встроенных систем поставляется в виде androidx.photopicker с артефактом Compose (photopicker-compose).

dependencies {
    implementation("androidx.photopicker:photopicker-compose:1.0.0-alpha01")
}

Точкой входа для Compose является композабл EmbeddedPhotoPicker и контейнер состояния, созданный с помощью rememberEmbeddedPhotoPickerState. В официальной документации описывается, что компонуемый объект создает SurfaceView, управляет подключением к сервису и передает выбранные URI обратно через коллбэки.

Ваш minSdk должен быть не ниже 34.

Ниже приведен пример кода, разработанного с использованием Compose, который изолирует логику выбора и делает остальную часть экрана тестируемой. Ключевые моменты: хранение выбранных URI в собственном состоянии, предоставление/отзыв разрешений URI через коллбэки и явное информирование выбора о разворачивании/сворачивании контейнера.

import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmbeddedPickerHost(
    maxSelection: Int = 5,
    onDone: (List<Uri>) -> Unit,
) {
    var attachments by remember { mutableStateOf(emptyList<Uri>()) }
    val scope = rememberCoroutineScope()

    val sheetState = rememberBottomSheetScaffoldState(
        bottomSheetState = rememberStandardBottomSheetState(
            initialValue = SheetValue.Hidden,
            skipHiddenState = false
        )
    )

    // Feature configuration is explicit in the embedded picker sample:
    // max limit + ordered selection + accent color.
    // Keep this object stable.
    val featureInfo = remember {
        EmbeddedPhotoPickerFeatureInfo.Builder()
            .setMaxSelectionLimit(maxSelection)
            .setOrderedSelection(true)
            .build()
    }

    val pickerState = rememberEmbeddedPhotoPickerState(
        onSelectionComplete = {
            scope.launch {
                sheetState.bottomSheetState.hide()
            }
            onDone(attachments)
        },
        onUriPermissionGranted = { granted ->
            attachments = attachments + granted
        },
        onUriPermissionRevoked = { revoked ->
            attachments = attachments - revoked.toSet()
        }
    )

    // Keep picker expansion in sync with the container.
    SideEffect {
        val expanded = sheetState.bottomSheetState.targetValue == SheetValue.Expanded
        pickerState.setCurrentExpanded(expanded)
    }

    BottomSheetScaffold(
        scaffoldState = sheetState,
        sheetPeekHeight = if (sheetState.bottomSheetState.isVisible) 400.dp else 0.dp,
        sheetContent = {
            // Dedicated picker surface area.
            EmbeddedPhotoPicker(
                modifier = Modifier
                    .fillMaxWidth()
                    .heightIn(min = 240.dp),
                state = pickerState,
                embeddedPhotoPickerFeatureInfo = featureInfo
            )
        },
        topBar = {
            TopAppBar(title = { Text("Composer") })
        }
    ) { innerPadding ->
        Column(Modifier.padding(innerPadding).padding(16.dp)) {
            Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
                Button(onClick = { scope.launch { sheetState.bottomSheetState.partialExpand() } }) {
                    Text("Add from gallery")
                }
                Button(
                    enabled = attachments.isNotEmpty(),
                    onClick = { onDone(attachments) }
                ) {
                    Text("Send")
                }
            }

            Spacer(Modifier.height(16.dp))

            // Your own attachment UI is separate from the picker surface.
            LazyVerticalGrid(
                columns = GridCells.Adaptive(minSize = 88.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp),
                horizontalArrangement = Arrangement.spacedBy(8.dp),
            ) {
                items(attachments) { uri ->
                    AttachmentTile(
                        uri = uri,
                        onRemove = {
                            scope.launch {
                                // Inform the picker that the host UI removed something.
                                pickerState.deselectUri(uri)
                                // Keep host state consistent (deselectUri won't auto-update your list).
                                attachments = attachments - uri
                            }
                        }
                    )
                }
            }
        }
    }
}

@Composable
private fun AttachmentTile(
    uri: Uri,
    onRemove: () -> Unit
) {
    // Replace with your image loader. Keep it simple here.
    Surface(
        tonalElevation = 2.dp,
        modifier = Modifier
            .size(88.dp)
            .clickable { onRemove() }
    ) { /* preview */ }
}

Всё в этом скелете соответствует рекомендациям платформы: встроенный компонент выбора предназначен для размещения внутри вашего пользовательского интерфейса, изменения выбора происходят непрерывно, а API-интерфейс построен на основе предоставления/отзыва разрешений для URI контента, а не на общих разрешениях для медиафайлов.

Синхронизация выбора, время жизни URI и фоновая работа

UX встроенного пикера наиболее эффективен, когда ваш основной пользовательский интерфейс и компонент выбора ведут себя как единая модель выбора. В документации Android по встроенным пикерам это явно указано: когда пользователь снимает выбор в вашем пользовательском интерфейсе, вы должны уведомить компонент выбора с помощью deselectUri / deselectUris. Есть важный нюанс: эти вызовы не запускают автоматически ваш коллбэк onUriPermissionRevoked, поэтому вы должны явно обновлять своё состояние.

Это поведение не случайно. Оно заставляет чётко определить зоны ответственности: пикер отвечает за то, что можно выбрать, а ваше приложение — за то, как выбор представлен и сохраняется в UI. В компоновщике сообщений пикер — это всего лишь панель, а не источник истины.

Другая неочевидная проблема, которая может аукнуться позже — время жизни доступа к URI. В документации Android для выбора фотографий указано, что доступ предоставляется до перезагрузки устройства или остановки приложения, и вы можете продлить срок действия доступа, вызвав метод takePersistableUriPermission(). Если ваш пользовательский интерфейс позволяет пользователям ставить в очередь загрузку или создавать черновики, это сразу же становится важным.

Обратите внимание на ограничение платформы: Android отмечает, что приложение может иметь до 5000 разрешений на доступ к медиафайлам одновременно, после чего старые разрешения удаляются. Этого достаточно для типичных сценариев использования, но это актуально для приложений, которые рассматривают выбор фотографий как конвейер обработки данных.

Что касается тестирования, то androidx.photopicker имеет специальный артефакт для тестирования и TestEmbeddedPhotoPickerProvider для поддержки тестовых сценариев, которые зависят от встроенного пикера.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: