この記事では、
最短でJetpack ComposeにCameraXを組み込むための方法について紹介します!
CameraXとは?
CameraXとは、カメラ機能の開発が簡単にできるJetpackライブラリです。
端末依存などを気にせず、一貫性のあるAPIで開発を行うことができます。
とても便利なライブラリなのですが、
ドキュメントや、公式のサンプルをみると、Android Viewでの実装例しか載っておらず、
Jetpack Composeでの実装例がありません。
今回は、CameraXをJetpack Composeで動かしたい!という人のために、
Jetpack Composeでの実装例を紹介していきます。
CameraXを動かすために必要なもの
具体的なコードの解説に入る前に、
まず、CameraXを動かすために必要なものを整理しましょう。
PreviewView
カメラのプレビューが表示されるUIです。
内部的には、SurfaceViewか、TextureViewのどちらか使用されます。
ProcessCameraProvider
アプリケーションのプロセス内の、任意のLifecycleOwner
に
カメラのライフサイクルをバインドするために必要です。
ProcessCameraProvider#bindToLifecycle
で、カメラのライフサイクルをバインドし、
ProcessCameraProvider#unbind
か
ProcessCameraProvider#unbindAll
で、解除します。
Preview
画面上にカメラのプレビューを表示するために必要です。
Preview.SurfaceProvider
を介して提供されるSurfaceに接続されます。
CameraSelector
使用するカメラを選択します。
内部のfilter関数で、端末が使用可能なカメラから、
使用したいカメラをフィルタリングします。
今回は、フロントカメラとバックカメラの選択に使用しています。
ImageCapture
写真を撮るために使用します。
ImageAnalysis
画像の解析に使用します。今回は特別な設定を行なっていません。
実装例
カメラのプレビューを表示するComposable関数を用意する
まず、カメラのプレビューを表示するために必要なComposable関数を作ります。
CameraXのComposable関数は標準で用意されていないので、
AndroidViewでラップする必要があります。
calculatePreviewSize
は未実装なので、エラーになりますが、
気にせず進めてください。
@Composable
internal fun CameraPreview(
modifier: Modifier = Modifier,
update: (PreviewView) -> Unit,
) {
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val previewSize =
remember(density, configuration) {
val windowSize =
with(density) {
IntSize(
width = configuration.screenWidthDp.dp.toPx().toInt(),
height = configuration.screenHeightDp.dp.toPx().toInt(),
)
}
calculatePreviewSize(windowSize) // 未実装
}
AndroidView(
modifier = modifier,
factory = { context ->
PreviewView(context).apply {
layoutParams =
android.view.ViewGroup.LayoutParams(
previewSize.width,
previewSize.height,
)
}
},
update = update,
)
}
LocalConfigurationから、端末の画面サイズを取得し、
そこからPreviewViewのサイズを決定しています。
次に、calculatePreviewSize
を実装します。
private fun calculatePreviewSize(windowSize: IntSize): IntSize {
val windowWidth = windowSize.width
val windowHeight = windowSize.height
val aspectRatio = 3f / 4f
val newWidth: Int
val newHeight: Int
if (windowWidth < windowHeight) {
newWidth = windowWidth
newHeight = (windowWidth * (1f / aspectRatio)).roundToInt()
} else {
newWidth = (windowHeight * (1f / aspectRatio)).roundToInt()
newHeight = windowHeight
}
return IntSize(newWidth, newHeight)
}
縦横比が3:4になるように計算しています。
横長の画面の場合は、4:3になります。
CameraXの処理をひとまとめにするユーティリティクラスの作成
CameraXを動かすために必要なクラスは複数ありますが、
それぞれを個別に扱うのは大変です。
なので、ひとまとめにして扱えるユーティリティクラスを作成します。
次のクラスを作成してください。
internal class CameraProvider {
data class CameraPreviewConfig(
@AspectRatio.Ratio val aspectRatio: Int,
@ImageOutputConfig.RotationValue val rotation: Int,
)
}
@Composable
internal fun rememberCameraProvider(): CameraProvider {
return remember {
CameraProvider()
}
}
CameraPreviewConfig
は、アスペクト比と回転角を保持します。
CameraProvider
に、CameraXを使うのに必要な処理を書いていきます
ProcessCameraProviderの取得処理
private fun getProvider(context: Context): ProcessCameraProvider {
return ProcessCameraProvider.getInstance(context).get()
}
CameraSelectorの取得処理
CameraSelectorを生成する際に、フロントとバックどちらのカメラを使用するか指定します。
フロント/バックの指定には、CameraSelectorの定数が用意されていますが、
そのままだと使いづらいので、独自のラッパークラスを作りましょう。
internal sealed interface LensFacing {
data object Front : LensFacing
data object Back : LensFacing
companion object {
fun from(value: Int): LensFacing {
return when (value) {
CameraSelector.LENS_FACING_FRONT -> Front
CameraSelector.LENS_FACING_BACK -> Back
else -> throw IllegalArgumentException("Unknown lens facing: $value")
}
}
}
@CameraSelector.LensFacing
fun toValue(): Int {
return when (this) {
Front -> CameraSelector.LENS_FACING_FRONT
Back -> CameraSelector.LENS_FACING_BACK
}
}
fun toInverse(): LensFacing {
return when (this) {
Front -> Back
Back -> Front
}
}
}
次に、CameraSelectorの取得処理を実装します。
fun hasBackCamera(context: Context): Boolean {
val provider = getProvider(context)
return provider.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA)
}
fun hasFrontCamera(context: Context): Boolean {
val provider = getProvider(context)
return provider.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA)
}
private fun buildCameraSelector(
context: Context,
requireLensFacing: LensFacing,
): CameraSelector {
val lensFacing =
when {
requireLensFacing is LensFacing.Front && hasFrontCamera(context) -> CameraSelector.LENS_FACING_FRONT
requireLensFacing is LensFacing.Back && hasBackCamera(context) -> CameraSelector.LENS_FACING_BACK
else -> throw IllegalStateException("Back and front camera are unavailable")
}
return CameraSelector.Builder().requireLensFacing(lensFacing).build()
}
Previewの取得処理
Previewを生成するさいには、現在のアスペクトと回転角が必要なので、
CameraPreviewConfig
の取得処理も一緒に実装します。
private fun getCameraProviderConfig(previewView: View): CameraPreviewConfig {
val rotation = previewView.context.display?.rotation ?: Surface.ROTATION_0
return CameraPreviewConfig(
aspectRatio = AspectRatio.RATIO_4_3,
rotation = rotation,
)
}
private fun buildPreview(previewView: View): Preview {
val (screenAspectRatio, rotation) = getCameraProviderConfig(previewView)
return Preview.Builder()
.setTargetAspectRatio(screenAspectRatio)
.setTargetRotation(rotation)
.build()
}
ImageCaptureの取得処理
private fun buildImageCapture(previewView: View): ImageCapture {
val (screenAspectRatio, rotation) = getCameraProviderConfig(previewView)
return ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.setTargetAspectRatio(screenAspectRatio)
.setTargetRotation(rotation)
.build()
}
ImageAnalysisの取得処理
private fun buildImageAnalysis(previewView: View): ImageAnalysis {
val (screenAspectRatio, rotation) = getCameraProviderConfig(previewView)
return ImageAnalysis.Builder()
.setTargetAspectRatio(screenAspectRatio)
.setTargetRotation(rotation)
.build()
}
プレビューの表示処理実装
必要な関数が実装しおわったので、プレビューの表示処理を実装しましょう。
fun bindCameraToPreview(
context: Context,
lifecycleOwner: LifecycleOwner,
previewView: PreviewView,
requireLensFacing: LensFacing = LensFacing.Back,
): CameraController {
val provider = getProvider(context)
val cameraSelector = buildCameraSelector(context, requireLensFacing)
val preview = buildPreview(previewView)
val imageCapture = buildImageCapture(previewView)
val imageAnalysis = buildImageAnalysis(previewView)
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageCapture,
imageAnalysis,
)
preview.setSurfaceProvider(previewView.surfaceProvider)
return CameraController(
imageCapture = imageCapture,
)
}
返り値のCameraControllerは、写真を撮るために使用します。
内部でImageCaptureを保持します。
internal class CameraController(
private val imageCapture: ImageCapture,
) {
companion object {
private const val FILENAME = "yyyy-MM-dd-HH-mm-ss-SSS"
private const val PHOTO_TYPE = "image/jpeg"
private const val DESTINATION_PATH = "Pictures/AppName"
}
fun takePicture(
context: Context,
executor: ExecutorService,
) {
val name =
SimpleDateFormat(FILENAME, Locale.US)
.format(System.currentTimeMillis())
val contentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
put(MediaStore.MediaColumns.MIME_TYPE, PHOTO_TYPE)
put(MediaStore.Images.Media.RELATIVE_PATH, DESTINATION_PATH)
}
val outputOptions =
ImageCapture.OutputFileOptions
.Builder(
context.contentResolver,
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues,
)
.build()
imageCapture.takePicture(
outputOptions,
executor,
object : ImageCapture.OnImageSavedCallback {
override fun onError(exception: ImageCaptureException) {}
override fun onImageSaved(output: ImageCapture.OutputFileResults) {}
},
)
}
}
takePictureが呼ばれると、
Picturesフォルダ配下に画像が保存されます。
カメラプレビューを表示してみよう
これでCameraXを使えるようになったので、
最後にMainActivityに組み込んで、動きを確かめてみましょう。
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraProvider = rememberCameraProvider()
var cameraController: CameraController? by remember {
mutableStateOf(null)
}
CameraPreview(
update = {
cameraController =
cameraProvider.bindCameraToPreview(
context = context,
lifecycleOwner = lifecycleOwner,
previewView = it,
requireLensFacing = LensFacing.Back,
)
},
)
}
}
}
}
下記のような表示がされるはずです。
おわりに
CameraXをJetpack Composeに組み込む方法について、解説させていただきましたが、
いかがだったでしょうか?
こちらのRepositoryに、今回の実装を組み込んだアプリを公開しているので、
コードをじっくり読みたい人は参考にしてみてください。
https://github.com/shunm-999/GeminiChat