diff --git a/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/main/groupdetail/GroupDetailRecentEvent.kt b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/main/groupdetail/GroupDetailRecentEvent.kt index d4c13603..41233184 100644 --- a/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/main/groupdetail/GroupDetailRecentEvent.kt +++ b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/main/groupdetail/GroupDetailRecentEvent.kt @@ -1,7 +1,6 @@ package com.mashup.gabbangzip.sharedalbum.presentation.ui.main.groupdetail import android.graphics.Bitmap -import android.graphics.Picture import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -14,7 +13,7 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration @@ -43,8 +42,9 @@ import com.mashup.gabbangzip.sharedalbum.presentation.ui.model.GroupKeyword import com.mashup.gabbangzip.sharedalbum.presentation.ui.model.GroupStatusType import com.mashup.gabbangzip.sharedalbum.presentation.ui.model.PicPhotoFrame import com.mashup.gabbangzip.sharedalbum.presentation.utils.ImmutableList -import com.mashup.gabbangzip.sharedalbum.presentation.utils.captureIntoCanvas -import com.mashup.gabbangzip.sharedalbum.presentation.utils.createBitmap +import com.mashup.gabbangzip.sharedalbum.presentation.utils.capturable +import com.mashup.gabbangzip.sharedalbum.presentation.utils.rememberCaptureController +import kotlinx.coroutines.launch @Composable fun RecentEventContainer( @@ -148,8 +148,8 @@ private fun PhotoCardWithShareButton( images: ImmutableList, onClickShareButton: (Bitmap) -> Unit, ) { - val picture = remember { Picture() } - + val captureController = rememberCaptureController() + val coroutineScope = rememberCoroutineScope() Column( modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, @@ -161,7 +161,7 @@ private fun PhotoCardWithShareButton( start = 41.5.dp, end = 41.5.dp, ) - .captureIntoCanvas(picture), + .capturable(captureController), keyword = keyword, date = date, title = title, @@ -172,8 +172,10 @@ private fun PhotoCardWithShareButton( iconRes = R.drawable.ic_share, isSingleClick = true, onButtonClicked = { - val bitmap = picture.createBitmap() - onClickShareButton(bitmap) + coroutineScope.launch { + val bitmap = captureController.captureAsync().await() + onClickShareButton(bitmap) + } }, ) } @@ -187,7 +189,7 @@ private fun PhotoCard( title: String, images: ImmutableList, ) { - val maxHeight = LocalConfiguration.current.screenHeightDp.dp.div(2) + val maxHeight = LocalConfiguration.current.screenHeightDp.dp Box( modifier = modifier @@ -199,29 +201,27 @@ private fun PhotoCard( keywordType = keyword, frameResId = PicPhotoFrame.getTypeByKeyword(keyword.name).frameResId, ) - Text( - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = 25.dp), - text = date, - color = Gray80, - style = PicTypography.bodyMedium16, - ) - PicFourPhotoGrid( - modifier = Modifier - .padding(top = 85.dp, bottom = 85.dp, start = 30.dp, end = 30.dp) - .align(Alignment.Center), - backgroundColor = keyword.frontCardBackgroundColor, - images = images, - ) - Text( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 31.dp), - text = title, - color = Gray80, - style = PicTypography.headBold20, - ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + modifier = Modifier + .padding(top = 30.dp), + text = date, + color = Gray80, + style = PicTypography.bodyMedium16, + ) + PicFourPhotoGrid( + modifier = Modifier + .padding(top = 28.dp, bottom = 23.dp, start = 30.dp, end = 30.dp), + backgroundColor = keyword.frontCardBackgroundColor, + images = images, + ) + Text( + modifier = Modifier.padding(bottom = 48.dp), + text = title, + color = Gray80, + style = PicTypography.headBold20, + ) + } } } diff --git a/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/main/groupdetail/HistoryDetailScreen.kt b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/main/groupdetail/HistoryDetailScreen.kt index 78278c9a..5d5c3f19 100644 --- a/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/main/groupdetail/HistoryDetailScreen.kt +++ b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/ui/main/groupdetail/HistoryDetailScreen.kt @@ -1,7 +1,6 @@ package com.mashup.gabbangzip.sharedalbum.presentation.ui.main.groupdetail import android.graphics.Bitmap -import android.graphics.Picture import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -15,7 +14,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -35,8 +34,9 @@ import com.mashup.gabbangzip.sharedalbum.presentation.ui.main.grouphome.model.Ca import com.mashup.gabbangzip.sharedalbum.presentation.ui.model.GroupKeyword import com.mashup.gabbangzip.sharedalbum.presentation.ui.model.PicPhotoFrame import com.mashup.gabbangzip.sharedalbum.presentation.utils.ImmutableList -import com.mashup.gabbangzip.sharedalbum.presentation.utils.captureIntoCanvas -import com.mashup.gabbangzip.sharedalbum.presentation.utils.createBitmap +import com.mashup.gabbangzip.sharedalbum.presentation.utils.capturable +import com.mashup.gabbangzip.sharedalbum.presentation.utils.rememberCaptureController +import kotlinx.coroutines.launch @Composable fun HistoryDetailScreen( @@ -46,7 +46,8 @@ fun HistoryDetailScreen( onClickBackButton: () -> Unit, onClickShareButton: (Bitmap) -> Unit, ) { - val picture = remember { Picture() } + val captureController = rememberCaptureController() + val coroutineScope = rememberCoroutineScope() Column( modifier = Modifier .fillMaxSize() @@ -68,7 +69,7 @@ fun HistoryDetailScreen( HistoryPhotoCard( modifier = Modifier .wrapContentSize() - .captureIntoCanvas(picture), + .capturable(captureController), keyword = keyword, item = item, ) @@ -80,8 +81,10 @@ fun HistoryDetailScreen( iconRes = R.drawable.ic_share, isSingleClick = true, onButtonClicked = { - val bitmap = picture.createBitmap() - onClickShareButton(bitmap) + coroutineScope.launch { + val bitmap = captureController.captureAsync().await() + onClickShareButton(bitmap) + } }, ) } diff --git a/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/utils/CaptureBitmapUtil.kt b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/utils/CaptureBitmapUtil.kt index ee33c606..16d3fac5 100644 --- a/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/utils/CaptureBitmapUtil.kt +++ b/presentation/src/main/java/com/mashup/gabbangzip/sharedalbum/presentation/utils/CaptureBitmapUtil.kt @@ -1,51 +1,178 @@ package com.mashup.gabbangzip.sharedalbum.presentation.utils +import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Picture +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.CacheDrawModifierNode import androidx.compose.ui.graphics.drawscope.draw import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement import androidx.core.content.FileProvider +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream import java.util.UUID import androidx.compose.ui.graphics.Canvas as ComposeCanvas -fun Modifier.captureIntoCanvas( - picture: Picture, -): Modifier = this.then( - drawWithCache { - // Example that shows how to redirect rendering to an Android Picture and then - // draw the picture into the original destination - val width = this.size.width.toInt() - val height = this.size.height.toInt() - onDrawWithContent { - val pictureCanvas = ComposeCanvas( - picture.beginRecording( - width, - height, - ), - ) - draw(this, this.layoutDirection, pictureCanvas, this.size) { - this@onDrawWithContent.drawContent() +/** + * [CaptureController.captureAsync] 를 호출했을 때 현재 Composable 의 [Bitmap] 을 반환하는 유틸 함수 + * + * modifier 안으로 [Picture] 객체를 전달하는 일반적인 방식은 Composable 함수 내부에 [AsyncImage] 가 있을 때 + * AsyncImage 가 로드되기 전에 [Picture] 객체에 그림을 그려버리는 문제점이 발생함 + * + * compose 1.7.0-alpha07 이상 (compose-bom 2024.09.01 이상) 부터는 [rememberGraphicsLayer] 을 제공 하고 + * 있어서, compose-bom 2024.09.01 이후엔 이를 활용 해보는 것도 좋아보임 + * + * @see (https://blog.shreyaspatil.dev/capturing-composable-to-a-bitmap-without-losing-a-state)[link] + * @see (https://github.com/PatilShreyas/Capturable/tree/master)[reference] + */ +fun Modifier.capturable(controller: CaptureController): Modifier { + return this then CapturableModifierNodeElement(controller) +} + +@SuppressLint("ModifierNodeInspectableProperties") +private data class CapturableModifierNodeElement( + private val controller: CaptureController +) : ModifierNodeElement() { + override fun create(): CapturableModifierNode { + return CapturableModifierNode(controller) + } + + override fun update(node: CapturableModifierNode) { + node.updateController(controller) + } +} + +private class CapturableModifierNode( + controller: CaptureController +) : DelegatingNode(), DelegatableNode { + + /** + * State to hold the current [CaptureController] instance. + * This can be updated via [updateController] method. + */ + private val currentController = MutableStateFlow(controller) + + override fun onAttach() { + super.onAttach() + coroutineScope.launch { + observeCaptureRequestsAndServe() + } + } + + /** + * Sets new [CaptureController] + */ + fun updateController(newController: CaptureController) { + currentController.value = newController + } + + @OptIn(ExperimentalCoroutinesApi::class) + private suspend fun observeCaptureRequestsAndServe() { + currentController + .flatMapLatest { it.captureRequests } + .collect { request -> + val completable = request.imageBitmapDeferred + try { + val picture = getCurrentContentAsPicture() + val bitmap = withContext(Dispatchers.Default) { + picture.createBitmap(request.config) + } + completable.complete(bitmap) + } catch (error: Throwable) { + completable.completeExceptionally(error) + } + } + } + + private suspend fun getCurrentContentAsPicture(): Picture { + return Picture().apply { drawCanvasIntoPicture(this) } + } + + /** + * Draws the current content into the provided [picture] + */ + private suspend fun drawCanvasIntoPicture(picture: Picture) { + // CompletableDeferred to wait until picture is drawn from the Canvas content + val pictureDrawn = CompletableDeferred() + + // Delegate the task to draw the content into the picture + val delegatedNode = delegate( + CacheDrawModifierNode { + val width = this.size.width.toInt() + val height = this.size.height.toInt() + + onDrawWithContent { + val pictureCanvas = ComposeCanvas(picture.beginRecording(width, height)) + + draw(this, this.layoutDirection, pictureCanvas, this.size) { + this@onDrawWithContent.drawContent() + } + picture.endRecording() + + drawIntoCanvas { canvas -> + canvas.nativeCanvas.drawPicture(picture) + + // Notify that picture is drawn + pictureDrawn.complete(Unit) + } + } } - picture.endRecording() + ) + // Wait until picture is drawn + pictureDrawn.await() - drawIntoCanvas { canvas -> canvas.nativeCanvas.drawPicture(picture) } + // As task is accomplished, remove the delegation of node to prevent draw operations on UI + // updates or recompositions. + undelegate(delegatedNode) + } +} + +class CaptureController { + private val _captureRequests = MutableSharedFlow(extraBufferCapacity = 1) + internal val captureRequests = _captureRequests.asSharedFlow() + + fun captureAsync(config: Bitmap.Config = Bitmap.Config.ARGB_8888): Deferred { + val deferredImageBitmap = CompletableDeferred() + return deferredImageBitmap.also { + _captureRequests.tryEmit(CaptureRequest(imageBitmapDeferred = it, config = config)) } - }, -) + } + + internal class CaptureRequest( + val imageBitmapDeferred: CompletableDeferred, + val config: Bitmap.Config + ) +} + +@Composable +fun rememberCaptureController(): CaptureController { + return remember { CaptureController() } +} -fun Picture.createBitmap(): Bitmap { +fun Picture.createBitmap(config: Bitmap.Config = Bitmap.Config.ARGB_8888): Bitmap { val bitmap = Bitmap.createBitmap( width, height, - Bitmap.Config.ARGB_8888, + config, ) val canvas = Canvas(bitmap)