android: make fullscreen images viewer with zoom

This commit is contained in:
Emin 2024-10-02 09:10:08 +05:00
parent 2d9820c745
commit 1077efd56c
13 changed files with 203 additions and 25 deletions

View file

@ -8,18 +8,20 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import app.organicmaps.R
@Composable
fun BackButton(
modifier: Modifier = Modifier,
size: Dp? = null,
onBackClick: () -> Unit,
tint: Color = MaterialTheme.colorScheme.onBackground
) {
Icon(
modifier = Modifier
.size(24.dp)
.size(size ?: 24.dp)
.clickable { onBackClick() }
.then(modifier),
painter = painterResource(id = R.drawable.back),

View file

@ -1,5 +1,6 @@
package app.tourism.ui.screens.main
import FullscreenImageScreen
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Composable
@ -54,6 +55,9 @@ object PersonalData
@Serializable
data class PlaceDetails(val id: Long)
@Serializable
data class FullscreenImageViewer(val selectedImageUrl: String, val imageUrls: List<String>)
@Composable
fun MainNavigation(rootNavController: NavHostController, themeVM: ThemeViewModel) {
val context = LocalContext.current
@ -63,6 +67,9 @@ fun MainNavigation(rootNavController: NavHostController, themeVM: ThemeViewModel
val onPlaceClick: (id: Long) -> Unit = { id ->
rootNavController.navigate(PlaceDetails(id = id))
}
val onPlaceImageClick: (String, List<String>) -> Unit = { selectedImage, imageUrls ->
rootNavController.navigate(FullscreenImageViewer(selectedImage, imageUrls))
}
val onSearchClick: (q: String) -> Unit = { q ->
rootNavController.navigate(Search(query = q))
}
@ -100,6 +107,7 @@ fun MainNavigation(rootNavController: NavHostController, themeVM: ThemeViewModel
val placeDetails = backStackEntry.toRoute<PlaceDetails>()
PlaceDetailsScreen(
id = placeDetails.id,
onPlaceImageClick = onPlaceImageClick,
onBackClick = onBackClick,
onMapClick = onMapClick,
onCreateRoute = { placeLocation ->
@ -107,6 +115,14 @@ fun MainNavigation(rootNavController: NavHostController, themeVM: ThemeViewModel
}
)
}
composable<FullscreenImageViewer> { backStackEntry ->
val fullscreenImageViewer = backStackEntry.toRoute<FullscreenImageViewer>()
FullscreenImageScreen(
onBackClick = onBackClick,
selectedImageUrl = fullscreenImageViewer.selectedImageUrl,
imageUrls = fullscreenImageViewer.imageUrls
)
}
composable<Search> { backStackEntry ->
val search = backStackEntry.toRoute<Search>()
SearchScreen(

View file

@ -29,6 +29,7 @@ import kotlinx.coroutines.launch
@Composable
fun PlaceDetailsScreen(
id: Long,
onPlaceImageClick: (selectedImage: String, imageUrls: List<String>) -> Unit,
onBackClick: () -> Unit,
onMapClick: () -> Unit,
onCreateRoute: (PlaceLocation) -> Unit,
@ -95,11 +96,21 @@ fun PlaceDetailsScreen(
}
1 -> {
GalleryNavigation(urls = place.pics)
GalleryNavigation(
urls = place.pics,
onItemClick = { item ->
onPlaceImageClick(item, place.pics)
},
)
}
2 -> {
ReviewsNavigation(placeId = place.id, rating = place.rating)
ReviewsNavigation(
placeId = place.id, rating = place.rating,
onImageClick = { selectedImageUrl, imageUrls ->
onPlaceImageClick(selectedImageUrl, imageUrls)
},
)
}
}
}

View file

@ -1,5 +1,6 @@
package app.tourism.ui.screens.main.place_details.gallery
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
@ -15,7 +16,7 @@ import app.tourism.ui.common.LoadImg
import app.tourism.ui.common.nav.BackButtonWithText
@Composable
fun AllGalleryScreen(urls: List<String>, onBackClick: () -> Unit) {
fun AllGalleryScreen(urls: List<String>, onItemClick: (String) -> Unit, onBackClick: () -> Unit) {
Scaffold(
topBar = {
BackButtonWithText { onBackClick() }
@ -30,7 +31,7 @@ fun AllGalleryScreen(urls: List<String>, onBackClick: () -> Unit) {
) {
items(urls) {
LoadImg(
modifier = Modifier.propertiesForSmallImage(), url = it
modifier = Modifier.clickable { onItemClick(it) }.propertiesForSmallImage(), url = it
)
}
}

View file

@ -0,0 +1,120 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import app.tourism.ui.common.nav.BackButton
import coil.compose.AsyncImage
import kotlinx.coroutines.launch
@Composable
fun FullscreenImageScreen(
onBackClick: () -> Unit,
selectedImageUrl: String,
imageUrls: List<String>
) {
val indexOfSelectedImage = imageUrls.indexOfFirst { it == selectedImageUrl }
val pagerState =
rememberPagerState(initialPage = if (indexOfSelectedImage != -1) indexOfSelectedImage else 0) { imageUrls.size }
val scope = rememberCoroutineScope()
Box(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
pageSize = PageSize.Fill,
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
var scale by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
var zooming by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures { centroid, pan, zoom, _ ->
if (zoom != 1f) zooming = true
scale = (scale * zoom).coerceIn(1f, 3f)
if (scale > 1f) {
val newOffset = offset + pan
val maxX = size.width * (scale - 1) / 2
val maxY = size.height * (scale - 1) / 2
offset = Offset(
newOffset.x.coerceIn(-maxX, maxX),
newOffset.y.coerceIn(-maxY, maxY)
)
} else {
offset = Offset.Zero
if (!zooming) {
// Allow swiping when not zoomed
scope.launch {
if (pan.x > 0 && pagerState.currentPage > 0) {
pagerState.animateScrollToPage(pagerState.currentPage - 1)
} else if (pan.x < 0 && pagerState.currentPage < imageUrls.size - 1) {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
}
}
zooming = false
}
}
) {
AsyncImage(
model = imageUrls[page],
contentDescription = "Full screen image",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y
)
)
}
}
Box(Modifier.padding(16.dp)) {
BackButton(
size = 30.dp,
onBackClick = onBackClick,
)
}
// Page indicator
Row(
Modifier
.height(50.dp)
.fillMaxWidth()
.align(Alignment.BottomCenter),
horizontalArrangement = Arrangement.Center
) {
repeat(imageUrls.size) { iteration ->
val color =
if (pagerState.currentPage == iteration) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.primary.copy(alpha = 0.25f)
Box(
modifier = Modifier
.padding(2.dp)
.clip(CircleShape)
.background(color)
.size(8.dp)
)
}
}
}
}

View file

@ -13,13 +13,14 @@ object Gallery
object AllGallery
@Composable
fun GalleryNavigation(urls: List<String>) {
fun GalleryNavigation(urls: List<String>, onItemClick: (String) -> Unit) {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Gallery) {
composable<Gallery> {
GalleryScreen(
urls = urls,
onItemClick = onItemClick,
onMoreClick = {
navController.navigate(AllGallery)
},
@ -28,6 +29,7 @@ fun GalleryNavigation(urls: List<String>) {
composable<AllGallery> {
AllGalleryScreen(
urls = urls,
onItemClick = onItemClick,
onBackClick = {
navController.navigateUp()
},

View file

@ -23,13 +23,16 @@ import app.tourism.ui.common.VerticalSpace
import app.tourism.ui.theme.TextStyles
@Composable
fun GalleryScreen(urls: List<String>, onMoreClick: () -> Unit) {
fun GalleryScreen(urls: List<String>, onItemClick: (String) -> Unit, onMoreClick: () -> Unit) {
Column(Modifier.padding(Constants.SCREEN_PADDING)) {
if (urls.isNotEmpty()) {
LoadImg(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clickable {
onItemClick(urls.first())
}
.clip(imageShape),
url = urls.first(),
)
@ -40,6 +43,7 @@ fun GalleryScreen(urls: List<String>, onMoreClick: () -> Unit) {
LoadImg(
modifier = Modifier
.weight(1f)
.clickable { onItemClick(urls[1]) }
.propertiesForSmallImage(),
url = urls[1],
)

View file

@ -13,10 +13,10 @@ import app.tourism.Constants
import app.tourism.ui.common.nav.BackButtonWithText
import app.tourism.ui.screens.main.place_details.reviews.components.Review
@Composable
fun AllReviewsScreen(
reviewsVM: ReviewsViewModel = hiltViewModel(),
onImageClick: (selectedImage: String, imageUrls: List<String>) -> Unit,
onBackClick: () -> Unit,
onMoreClick: (picsUrls: List<String>) -> Unit,
) {
@ -32,7 +32,7 @@ fun AllReviewsScreen(
contentPadding = PaddingValues(Constants.SCREEN_PADDING),
) {
items(reviews) {
Review(review = it, onMoreClick = onMoreClick)
Review(review = it, onMoreClick = onMoreClick, onImageClick = onImageClick)
}
}
}

View file

@ -1,5 +1,6 @@
package app.tourism.ui.screens.main.place_details.reviews
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
@ -16,7 +17,11 @@ import app.tourism.ui.common.nav.BackButtonWithText
import app.tourism.ui.screens.main.place_details.gallery.propertiesForSmallImage
@Composable
fun ReviewPicsScreen(urls: List<String>, onBackClick: () -> Unit) {
fun ReviewPicsScreen(
urls: List<String>,
onImageClick: (selectedImage: String, imageUrls: List<String>) -> Unit,
onBackClick: () -> Unit
) {
Scaffold(
topBar = {
BackButtonWithText { onBackClick() }
@ -31,7 +36,9 @@ fun ReviewPicsScreen(urls: List<String>, onBackClick: () -> Unit) {
) {
items(urls) {
LoadImg(
modifier = Modifier.propertiesForSmallImage(), url = it
modifier = Modifier
.clickable { onImageClick(it, urls) }
.propertiesForSmallImage(), url = it
)
}
}

View file

@ -22,6 +22,7 @@ data class ReviewPics(val urls: List<String>)
fun ReviewsNavigation(
placeId: Long,
rating: Double?,
onImageClick: (selectedImage: String, imageUrls: List<String>) -> Unit,
reviewsVM: ReviewsViewModel = hiltViewModel(),
) {
val navController = rememberNavController()
@ -40,6 +41,7 @@ fun ReviewsNavigation(
ReviewsScreen(
placeId,
rating,
onImageClick = onImageClick,
onSeeAllClick = {
navController.navigate(AllReviews)
},
@ -50,6 +52,7 @@ fun ReviewsNavigation(
composable<AllReviews> {
AllReviewsScreen(
reviewsVM = reviewsVM,
onImageClick = onImageClick,
onBackClick = onBackClick,
onMoreClick = onMoreClick
)
@ -58,6 +61,7 @@ fun ReviewsNavigation(
val reviewPics = navBackStackEntry.toRoute<ReviewPics>()
ReviewPicsScreen(
urls = reviewPics.urls,
onImageClick = onImageClick,
onBackClick = onBackClick
)
}

View file

@ -47,6 +47,7 @@ import kotlinx.coroutines.launch
fun ReviewsScreen(
placeId: Long,
rating: Double?,
onImageClick: (selectedImage: String, imageUrls: List<String>) -> Unit,
onSeeAllClick: () -> Unit,
onMoreClick: (picsUrls: List<String>) -> Unit,
reviewsVM: ReviewsViewModel = hiltViewModel(),
@ -60,7 +61,8 @@ fun ReviewsScreen(
val userReview = reviewsVM.userReview.collectAsState().value
val reviews = reviewsVM.reviews.collectAsState().value
val isThereReviewPlannedToPublish = reviewsVM.isThereReviewPlannedToPublish.collectAsState().value
val isThereReviewPlannedToPublish =
reviewsVM.isThereReviewPlannedToPublish.collectAsState().value
ObserveAsEvents(flow = reviewsVM.uiEventsChannelFlow) { event ->
if (event is UiEvent.ShowToast) context.showToast(event.message)
@ -123,6 +125,7 @@ fun ReviewsScreen(
item {
Review(
review = it,
onImageClick = onImageClick,
onMoreClick = onMoreClick,
onDeleteClick = {
showYesNoAlertDialog(
@ -137,7 +140,11 @@ fun ReviewsScreen(
if (reviews.firstOrNull() != null)
item {
Review(review = reviews[0], onMoreClick = onMoreClick)
Review(
review = reviews[0],
onMoreClick = onMoreClick,
onImageClick = onImageClick,
)
}
}

View file

@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
@ -34,7 +33,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@ -45,10 +43,8 @@ import app.tourism.domain.models.details.User
import app.tourism.ui.common.HorizontalSpace
import app.tourism.ui.common.LoadImg
import app.tourism.ui.common.VerticalSpace
import app.tourism.ui.common.special.CountryAsLabel
import app.tourism.ui.common.special.CountryFlag
import app.tourism.ui.common.special.RatingBar
import app.tourism.ui.screens.main.place_details.gallery.imageShape
import app.tourism.ui.theme.TextStyles
import app.tourism.ui.theme.getHintColor
@ -57,6 +53,7 @@ import app.tourism.ui.theme.getHintColor
fun Review(
modifier: Modifier = Modifier,
review: Review,
onImageClick: (selectedImage: String, imageUrls: List<String>) -> Unit,
onMoreClick: (picsUrls: List<String>) -> Unit,
onDeleteClick: (() -> Unit)? = null,
) {
@ -101,14 +98,19 @@ fun Review(
remaining = review.picsUrls.size - 3
)
} else {
ReviewPic(url = url)
ReviewPic(modifier = Modifier.clickable {
onImageClick(
url,
review.picsUrls
)
}, url = url)
}
}
}
VerticalSpace(height = 16.dp)
}
if(!review.comment.isNullOrBlank()) {
if (!review.comment.isNullOrBlank()) {
Comment(comment = review.comment)
VerticalSpace(height = 16.dp)
}
@ -150,7 +152,9 @@ fun User(modifier: Modifier = Modifier, user: User) {
)
}
Text(
modifier = Modifier.weight(1f).width(IntrinsicSize.Min),
modifier = Modifier
.weight(1f)
.width(IntrinsicSize.Min),
text = user.name,
style = TextStyles.h4,
fontWeight = FontWeight.W600,
@ -203,7 +207,7 @@ fun ReviewPic(modifier: Modifier = Modifier, url: String) {
modifier = Modifier
.width(73.dp)
.height(65.dp)
.clip(imageShape)
.clip(localImageShape)
.then(modifier),
url = url,
)
@ -223,7 +227,7 @@ fun ShowMore(url: String, onClick: () -> Unit, remaining: Int) {
.fillMaxSize()
.background(
color = Color.Black.copy(alpha = 0.5f),
shape = imageShape
shape = localImageShape
),
)
Text(
@ -239,6 +243,6 @@ fun Modifier.getImageProperties() =
this
.width(73.dp)
.height(65.dp)
.clip(imageShape)
.clip(localImageShape)
val imageShape = RoundedCornerShape(4.dp)
val localImageShape = RoundedCornerShape(4.dp)

View file

@ -21,7 +21,7 @@ struct ReviewsScreen: View {
var body: some View {
ScrollView {
VStack {
// overal rating
// overall rating
HStack(alignment: .center) {
Image(systemName: "star.fill")
.resizable()