diff --git a/feature/club/src/main/java/in/koreatech/koin/feature/club/ui/clubdetail/ClubDetail.kt b/feature/club/src/main/java/in/koreatech/koin/feature/club/ui/clubdetail/ClubDetail.kt index 88c5a914d..516f35271 100644 --- a/feature/club/src/main/java/in/koreatech/koin/feature/club/ui/clubdetail/ClubDetail.kt +++ b/feature/club/src/main/java/in/koreatech/koin/feature/club/ui/clubdetail/ClubDetail.kt @@ -3,6 +3,8 @@ package `in`.koreatech.koin.feature.club.ui.clubdetail import android.content.Context import android.content.Intent import android.net.Uri +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -22,7 +24,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState @@ -43,24 +44,28 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel @@ -109,6 +114,8 @@ import `in`.koreatech.koin.feature.club.ui.clubdetail.events.ClubDetailEvents import `in`.koreatech.koin.feature.club.ui.clubdetail.intro.ClubDetailIntro import `in`.koreatech.koin.feature.club.ui.clubdetail.qna.ClubDetailQna import `in`.koreatech.koin.feature.club.ui.clubdetail.recruit.ClubDetailRecruit +import `in`.koreatech.koin.feature.club.ui.clubdetail.scroll.clubDetailScrollConnection +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect @@ -133,17 +140,19 @@ fun ClubDetail( val state by viewModel.collectAsState() val density = LocalDensity.current - val detailList = listOf( - Pair(DETAIL_CATEGORY, state.clubDetails?.category), - Pair(DETAIL_LOCATION, state.clubDetails?.location), - Pair(DETAIL_DESCRIPTION, state.clubDetails?.description), - Pair(DETAIL_INSTAGRAM, state.clubDetails?.instagram), - Pair(DETAIL_GOOGLE_FORM, state.clubDetails?.googleForm), - Pair(DETAIL_OPEN_CHAT, state.clubDetails?.openChat), - Pair(DETAIL_PHONE_NUMBER, state.clubDetails?.phoneNumber) - ) - val qnaList = state.clubQnasInfo?.qnas - val tabList = DetailTabType.entries.map { it.strResId } + val detailList = remember(state.clubDetails) { + persistentListOf( + Pair(DETAIL_CATEGORY, state.clubDetails?.category), + Pair(DETAIL_LOCATION, state.clubDetails?.location), + Pair(DETAIL_DESCRIPTION, state.clubDetails?.description), + Pair(DETAIL_INSTAGRAM, state.clubDetails?.instagram), + Pair(DETAIL_GOOGLE_FORM, state.clubDetails?.googleForm), + Pair(DETAIL_OPEN_CHAT, state.clubDetails?.openChat), + Pair(DETAIL_PHONE_NUMBER, state.clubDetails?.phoneNumber) + ) + } + var qnaList by remember(state.clubQnasInfo) { mutableStateOf(state.clubQnasInfo?.qnas) } + var tabList by remember { mutableStateOf(DetailTabType.entries.map { it.strResId }) } val pagerState = rememberPagerState(initialPage = initialPage) { tabList.size } val scope = rememberCoroutineScope() @@ -152,29 +161,68 @@ fun ClubDetail( val snackbarHostState = remember { SnackbarHostState() } - val listState = rememberLazyListState() - val introductionScrollState = rememberScrollState() - val isIntroductionScrollable = remember { derivedStateOf { !listState.canScrollForward || introductionScrollState.value != 0 } } - val recruitScrollState = rememberScrollState() - val isRecruitScrollable = remember { derivedStateOf { !listState.canScrollForward || recruitScrollState.value != 0 } } - val eventsScrollState = rememberScrollState() - val isEventsScrollable = remember { derivedStateOf { !listState.canScrollForward || eventsScrollState.value != 0 } } - val qnaScrollState = rememberScrollState() - val isQnaScrollable = remember { derivedStateOf { !listState.canScrollForward || qnaScrollState.value != 0 } } - var tabRowHeight by remember { mutableStateOf(0.dp) } + var maxToolbarHeight by remember { mutableStateOf(0.dp) } + val minToolbarHeight = 0.dp + val maxToolbarHeightPx = remember(maxToolbarHeight) { with(density) { maxToolbarHeight.toPx() } } + val minToolbarHeightPx = with(density) { minToolbarHeight.toPx() } + val toolbarOffsetPx = remember { mutableFloatStateOf(0f) } val navigator = rememberNavigator() + val currentScrollState = remember { + derivedStateOf { + when (tabList[pagerState.currentPage]) { + DetailTabType.DETAIL_INTRO.strResId -> introductionScrollState + DetailTabType.QNA.strResId -> qnaScrollState + DetailTabType.EVENT.strResId -> eventsScrollState + DetailTabType.RECRUIT.strResId -> recruitScrollState + else -> introductionScrollState + } + } + } + + val nestedScrollConnection = remember(maxToolbarHeightPx) { + clubDetailScrollConnection( + currentScrollState = currentScrollState, + toolbarOffsetPx = toolbarOffsetPx, + maxToolbarHeightPx = maxToolbarHeightPx, + minToolbarHeightPx = minToolbarHeightPx + ) + } + + val toolbarHeight = lerp( + maxToolbarHeight, + minToolbarHeight, + if (maxToolbarHeightPx != minToolbarHeightPx) { + -toolbarOffsetPx.floatValue / (maxToolbarHeightPx - minToolbarHeightPx) + } else { + 0f + } + ) + val animatedToolbarHeight by animateDpAsState( + targetValue = toolbarHeight, + animationSpec = tween(durationMillis = 50) + ) + + var toolBarFullScrollEvent by remember { mutableStateOf(false) } + + LaunchedEffect(toolBarFullScrollEvent, maxToolbarHeight) { + if (maxToolbarHeight.value != 0f && toolBarFullScrollEvent) { + toolbarOffsetPx.floatValue = -maxToolbarHeightPx + toolBarFullScrollEvent = false + } + } + viewModel.collectSideEffect { sideEffect -> handleSideEffect(sideEffect, context, snackbarHostState) } - LaunchedEffect(Unit, state.clubId) { + LaunchedEffect(state.clubId) { if (state.clubId != -1) { viewModel.fetchAllData() } @@ -193,7 +241,7 @@ fun ClubDetail( LaunchedEffect(isRecruitEvent) { if (isRecruitEvent) { pagerState.animateScrollToPage(1) - listState.animateScrollToItem(2) + toolBarFullScrollEvent = true } } @@ -203,7 +251,7 @@ fun ClubDetail( if (eventIndex != -1) { viewModel.selectEvent(eventIndex) pagerState.animateScrollToPage(2) - listState.animateScrollToItem(2) + toolBarFullScrollEvent = true resetNorificationEventId() } } @@ -225,8 +273,8 @@ fun ClubDetail( shape = CircleShape, onClick = { scope.launch { - listState.animateScrollToItem(0) - qnaScrollState.scrollTo(0) + toolbarOffsetPx.floatValue = minToolbarHeightPx + currentScrollState.value.scrollTo(0) } }, elevation = FloatingActionButtonDefaults.elevation( @@ -428,323 +476,349 @@ fun ClubDetail( ) } - LazyColumn( + Column( modifier = Modifier .padding(contentPadding) .consumeWindowInsets(contentPadding) .systemBarsPadding() - .fillMaxSize(), - state = listState, - horizontalAlignment = Alignment.CenterHorizontally + .fillMaxSize() + .nestedScroll(nestedScrollConnection) ) { - item { - SubcomposeAsyncImage( - modifier = Modifier - .size(200.dp) - .clickable { - viewModel.showImageDialog(state.clubDetails?.imageUrl ?: "") - }, - model = ImageRequest.Builder(context) - .data(state.clubDetails?.imageUrl) - .size(400) - .build(), - contentDescription = "Club Image", - contentScale = ContentScale.Crop, - alignment = Alignment.Center, - loading = { - Box( - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - }, - error = { - Box( - contentAlignment = Alignment.Center - ) { - Text(stringResource(R.string.detail_club_image_error)) + /* + * LazyColumn can detect scroll gestures in nestedScrollConnection + * With 'reverseLayout = true', items can disappear starting from the top + */ + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .then( + if (maxToolbarHeight.value == 0f) { + Modifier.onSizeChanged { size -> + maxToolbarHeight = with(density) { size.height.toDp() } + } + } else { + Modifier.height(animatedToolbarHeight) } - } - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = 24.dp, - vertical = 16.dp - ) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically + ), + reverseLayout = true + ) { + item { + /* + * Column in LazyColumn Because of `reverseLayout = true` option + */ + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = 24.dp, + vertical = 16.dp + ), + horizontalAlignment = Alignment.CenterHorizontally ) { - state.userId?.let { - if (state.clubDetails?.manager == true) { - Row( - verticalAlignment = Alignment.CenterVertically + SubcomposeAsyncImage( + modifier = Modifier + .size(200.dp) + .clickable { + viewModel.showImageDialog(state.clubDetails?.imageUrl ?: "") + }, + model = ImageRequest.Builder(context) + .data(state.clubDetails?.imageUrl) + .size(400) + .build(), + contentDescription = "Club Image", + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + loading = { + Box( + contentAlignment = Alignment.Center ) { - FilledButton( - text = stringResource(R.string.detail_empowerment_button), - onClick = { - viewModel.showEmpowermentDialog() - EventLogger.logCampusClickEvent( - AnalyticsConstant.Label.Club.CLUB_DELEGATION_AUTHORITY, - "권한위임" - ) - }, - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 5.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - FilledButton( - text = stringResource(R.string.detail_fix_button), - onClick = { - EventLogger.logCampusClickEvent( - AnalyticsConstant.Label.Club.CLUB_CORRECTION, - "수정하기" - ) - onModifyClick(state.clubId) - }, // 동아리 정보 수정 버튼 클릭 - contentPadding = PaddingValues(horizontal = 25.dp, vertical = 5.dp) - ) + CircularProgressIndicator() + } + }, + error = { + Box( + contentAlignment = Alignment.Center + ) { + Text(stringResource(R.string.detail_club_image_error)) } } - } - } - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = state.clubDetails?.name ?: "", - style = KoinTheme.typography.bold20, - modifier = Modifier - .padding(end = 16.dp) - .weight(1f, fill = false), - maxLines = 2, - overflow = TextOverflow.Ellipsis ) Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { - state.clubDetails?.hotStatus?.let { - Row( - modifier = Modifier - .background( - color = KoinTheme.colors.primary100, - shape = KoinTheme.shapes.extraSmall + state.userId?.let { + if (state.clubDetails?.manager == true) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + FilledButton( + text = stringResource(R.string.detail_empowerment_button), + onClick = { + viewModel.showEmpowermentDialog() + EventLogger.logCampusClickEvent( + AnalyticsConstant.Label.Club.CLUB_DELEGATION_AUTHORITY, + "권한위임" + ) + }, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 5.dp) ) - .padding(vertical = 7.dp, horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource( - R.string.detail_hotStatus_club_intro, - state.clubDetails?.hotStatus?.month ?: 0, - state.clubDetails?.hotStatus?.weekOfMonth ?: 0 - ), - style = KoinTheme.typography.regular10.copy(fontSize = 11.sp), - color = KoinTheme.colors.neutral800 - ) + Spacer(modifier = Modifier.width(8.dp)) + FilledButton( + text = stringResource(R.string.detail_fix_button), + onClick = { + EventLogger.logCampusClickEvent( + AnalyticsConstant.Label.Club.CLUB_CORRECTION, + "수정하기" + ) + onModifyClick(state.clubId) + }, // 동아리 정보 수정 버튼 클릭 + contentPadding = PaddingValues(horizontal = 25.dp, vertical = 5.dp) + ) + } } } - Spacer(Modifier.width(8.dp)) - Image( - painter = if (state.clubDetails?.isLiked == true) painterResource(id = R.drawable.icon_like_true) else painterResource(id = R.drawable.icon_like_false), - contentDescription = "", + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = state.clubDetails?.name ?: "", + style = KoinTheme.typography.bold20, modifier = Modifier - .size(24.dp) - .padding(end = 4.dp) - .clickable { - state.userId?.let { - viewModel.changeClubLike() - } ?: viewModel.showLoginDialog() - } + .padding(end = 16.dp) + .weight(1f, fill = false), + maxLines = 2, + overflow = TextOverflow.Ellipsis ) - if (state.clubDetails?.isLikeHidden != true) { - Text( - text = "${state.clubDetails?.likes}", - style = KoinTheme.typography.medium14 + Row( + verticalAlignment = Alignment.CenterVertically + ) { + state.clubDetails?.hotStatus?.let { + Row( + modifier = Modifier + .background( + color = KoinTheme.colors.primary100, + shape = KoinTheme.shapes.extraSmall + ) + .padding(vertical = 7.dp, horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource( + R.string.detail_hotStatus_club_intro, + state.clubDetails?.hotStatus?.month ?: 0, + state.clubDetails?.hotStatus?.weekOfMonth ?: 0 + ), + style = KoinTheme.typography.regular10.copy(fontSize = 11.sp), + color = KoinTheme.colors.neutral800 + ) + } + } + Spacer(Modifier.width(8.dp)) + Image( + imageVector = if (state.clubDetails?.isLiked == true) ImageVector.vectorResource(id = R.drawable.icon_like_true) else ImageVector.vectorResource(id = R.drawable.icon_like_false), + contentDescription = "", + modifier = Modifier + .size(24.dp) + .padding(end = 4.dp) + .clickable { + state.userId?.let { + viewModel.changeClubLike() + } ?: viewModel.showLoginDialog() + } ) + if (state.clubDetails?.isLikeHidden != true) { + Text( + text = state.clubDetails?.likes.toString(), + style = KoinTheme.typography.medium14 + ) + } } } - } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - detailList.forEach { intro -> - if (intro.second.isNullOrBlank()) return@forEach - var outputText = "" - var linkUrl = "" - val showMore = remember { mutableStateOf(false) } - var icon = -1 - var onClick = {} - var onIconClick = {} - val clipboard = LocalClipboardManager.current - intro.second?.let { - when (intro.first) { - DETAIL_DESCRIPTION -> { - outputText = stringResource(intro.first.strResId, it) - onClick = { showMore.value = !showMore.value } - } - DETAIL_INSTAGRAM -> { - val url = if (it.isValidUrlScheme()) it else it.toHttpsUrl() - linkUrl = if (url.isValidInstagramUrl()) url else url.removeUrlScheme().toInstagramUrl() - onClick = { viewModel.openUrl(linkUrl) } - outputText = it.toInstagramLink() - } - DETAIL_GOOGLE_FORM -> { - val url = if (it.isValidUrlScheme()) it else it.toHttpsUrl() - linkUrl = if (url.isValidGoogleFormUrl()) url else "" - onClick = { viewModel.openUrl(linkUrl) } - outputText = it.removeUrlScheme().let { text -> if (text.length <= 22) text else "${text.take(22)}..." } - icon = R.drawable.icon_club_copy - onIconClick = { clipboard.setText(AnnotatedString(linkUrl)) } - } - DETAIL_OPEN_CHAT -> { - val url = if (it.isValidUrlScheme()) it else it.toHttpsUrl() - linkUrl = if (url.isValidOpenChatUrl()) url else "" - onClick = { viewModel.openUrl(linkUrl) } - outputText = it.removeUrlScheme() - icon = R.drawable.icon_club_copy - onIconClick = { clipboard.setText(AnnotatedString(linkUrl)) } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + detailList.forEach { intro -> + if (intro.second.isNullOrBlank()) return@forEach + var outputText by remember { mutableStateOf("") } + var linkUrl by remember { mutableStateOf("") } + val showMore = remember { mutableStateOf(false) } + var icon by remember { mutableStateOf(-1) } + var onClick by remember { mutableStateOf({}) } + var onIconClick by remember { mutableStateOf({}) } + val clipboard = LocalClipboardManager.current + intro.second?.let { + when (intro.first) { + DETAIL_DESCRIPTION -> { + outputText = stringResource(intro.first.strResId, it) + onClick = { showMore.value = !showMore.value } + } + DETAIL_INSTAGRAM -> { + val url = if (it.isValidUrlScheme()) it else it.toHttpsUrl() + linkUrl = if (url.isValidInstagramUrl()) url else url.removeUrlScheme().toInstagramUrl() + onClick = { viewModel.openUrl(linkUrl) } + outputText = it.toInstagramLink() + } + DETAIL_GOOGLE_FORM -> { + val url = if (it.isValidUrlScheme()) it else it.toHttpsUrl() + linkUrl = if (url.isValidGoogleFormUrl()) url else "" + onClick = { viewModel.openUrl(linkUrl) } + outputText = it.removeUrlScheme().let { text -> if (text.length <= 22) text else "${text.take(22)}..." } + icon = R.drawable.icon_club_copy + onIconClick = { clipboard.setText(AnnotatedString(linkUrl)) } + } + DETAIL_OPEN_CHAT -> { + val url = if (it.isValidUrlScheme()) it else it.toHttpsUrl() + linkUrl = if (url.isValidOpenChatUrl()) url else "" + onClick = { viewModel.openUrl(linkUrl) } + outputText = it.removeUrlScheme() + icon = R.drawable.icon_club_copy + onIconClick = { clipboard.setText(AnnotatedString(linkUrl)) } + } + DETAIL_PHONE_NUMBER -> { + outputText = if (it.isValidPhoneNumber) it.formatPhoneNumber() else it + icon = R.drawable.icon_club_copy + onIconClick = { clipboard.setText(AnnotatedString(it)) } + } + else -> outputText = it } - DETAIL_PHONE_NUMBER -> { - outputText = if (it.isValidPhoneNumber) it.formatPhoneNumber() else it - icon = R.drawable.icon_club_copy - onIconClick = { clipboard.setText(AnnotatedString(it)) } + if ( + it.isValidGoogleFormUrl() || + it.isValidOpenChatUrl() + ) { + linkUrl = it + onClick = { if (linkUrl.isNotEmpty()) viewModel.openUrl(linkUrl) } } - else -> outputText = it } - if ( - it.isValidGoogleFormUrl() || - it.isValidOpenChatUrl() + Row( + verticalAlignment = Alignment.CenterVertically ) { - linkUrl = it - onClick = { if (linkUrl.isNotEmpty()) viewModel.openUrl(linkUrl) } + Text( + text = if (intro.first != DETAIL_DESCRIPTION) stringResource(intro.first.strResId) else "", + style = KoinTheme.typography.medium18, + color = KoinTheme.colors.neutral800 + ) + Text( + text = outputText, + maxLines = if (intro.first == DETAIL_DESCRIPTION) if (showMore.value) 10 else 2 else 1, + style = KoinTheme.typography.medium18, + color = if (linkUrl.isEmpty()) KoinTheme.colors.neutral800 else KoinTheme.colors.info700, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .clickable { onClick() } + .weight(1f, fill = false) + ) + if (icon != -1) { + Spacer(Modifier.width(8.dp)) + Image( + imageVector = ImageVector.vectorResource(id = icon), + contentDescription = "Copy Icon", + modifier = Modifier + .size(24.dp) + .padding(end = 4.dp) + .clickable { + onIconClick() + } + ) + } } } - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = if (intro.first != DETAIL_DESCRIPTION) stringResource(intro.first.strResId) else "", - style = KoinTheme.typography.medium18, - color = KoinTheme.colors.neutral800 - ) - Text( - text = outputText, - maxLines = if (intro.first == DETAIL_DESCRIPTION) if (showMore.value) 10 else 2 else 1, - style = KoinTheme.typography.medium18, - color = if (linkUrl.isEmpty()) KoinTheme.colors.neutral800 else KoinTheme.colors.info700, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.clickable { onClick() }.weight(1f, fill = false) - ) - if (icon != -1) { + if (state.clubDetails?.manager == false) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.detail_intro_notification), + style = KoinTheme.typography.medium18, + color = KoinTheme.colors.neutral800 + ) Spacer(Modifier.width(8.dp)) Image( - painter = painterResource(id = icon), - contentDescription = "Phone Number Copy Icon", + imageVector = if (state.clubDetails?.isRecruitSubscribed == true) { + ImageVector.vectorResource(R.drawable.icon_notification_true) + } else { + ImageVector.vectorResource(R.drawable.icon_notification_false) + }, + contentDescription = "Notification Icon", modifier = Modifier .size(24.dp) .padding(end = 4.dp) .clickable { - onIconClick() + EventLogger.logCampusClickEvent( + AnalyticsConstant.Label.Club.CLUB_RECRUITMENT_NOTI, + state.clubDetails?.name ?: "알 수 없는 동아리" + ) + state.userId?.let { + viewModel.updateRecruitSubscribeDialog(true) + } ?: viewModel.showLoginDialog() } ) } } } - if (state.clubDetails?.manager == false) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.detail_intro_notification), - style = KoinTheme.typography.medium18, - color = KoinTheme.colors.neutral800 - ) - Spacer(Modifier.width(8.dp)) - Image( - painter = if (state.clubDetails?.isRecruitSubscribed == true) { - painterResource(R.drawable.icon_notification_true) - } else { - painterResource(R.drawable.icon_notification_false) - }, - contentDescription = "Notification Icon", - modifier = Modifier - .size(24.dp) - .padding(end = 4.dp) - .clickable { - EventLogger.logCampusClickEvent( - AnalyticsConstant.Label.Club.CLUB_RECRUITMENT_NOTI, - state.clubDetails?.name ?: "알 수 없는 동아리" - ) - state.userId?.let { - viewModel.updateRecruitSubscribeDialog(true) - } ?: viewModel.showLoginDialog() - } - ) - } + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + imageVector = ImageVector.vectorResource(id = R.drawable.icon_club_info), + contentDescription = "picture", + modifier = Modifier + .size(17.dp) + .padding(end = 4.dp) + ) + Text( + text = state.clubDetails?.updatedAt ?: "", + style = KoinTheme.typography.regular12, + color = KoinTheme.colors.neutral600 + ) + Text( + text = stringResource(R.string.detail_date_text), + style = KoinTheme.typography.regular12, + color = KoinTheme.colors.neutral600 + ) } } - Spacer(modifier = Modifier.height(12.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Image( - painter = painterResource(id = R.drawable.icon_club_info), - contentDescription = "picture", - modifier = Modifier - .size(17.dp) - .padding(end = 4.dp) - ) - Text( - text = state.clubDetails?.updatedAt ?: "", - style = KoinTheme.typography.regular12, - color = KoinTheme.colors.neutral600 - ) - Text( - text = stringResource(R.string.detail_date_text), - style = KoinTheme.typography.regular12, - color = KoinTheme.colors.neutral600 - ) - } } } - stickyHeader { - DetailTabRow( - modifier = Modifier.zIndex(2f).onGloballyPositioned { - tabRowHeight = with(density) { - it.size.height.toDp() - } - }, - selectedTabIndex = pagerState.currentPage, - onTabSelected = { - EventLogger.logCampusClickEvent( - AnalyticsConstant.Label.Club.CLUB_TAB_SELECT, - context.getString(tabList[it]) - ) - scope.launch { - pagerState.animateScrollToPage(it) - } - }, - titles = tabList.map { stringResource(it) } - ) + /* + * LazyColumn can detect scroll gestures in nestedScrollConnection + */ + LazyColumn { + item { + DetailTabRow( + modifier = Modifier.zIndex(2f), + selectedTabIndex = pagerState.currentPage, + onTabSelected = { + EventLogger.logCampusClickEvent( + AnalyticsConstant.Label.Club.CLUB_TAB_SELECT, + context.getString(tabList[it]) + ) + scope.launch { + pagerState.animateScrollToPage(it) + } + }, + titles = tabList.map { stringResource(it) } + ) + } } - item { - val deviceHeightDp = LocalConfiguration.current.screenHeightDp.dp - (contentPadding.calculateTopPadding().value.dp + 24.dp) - tabRowHeight + + Column( + modifier = Modifier.fillMaxSize() + ) { HorizontalPager( modifier = Modifier - .fillMaxSize() - .height(deviceHeightDp), + .fillMaxSize(), state = pagerState, verticalAlignment = Alignment.Top ) { page -> @@ -756,7 +830,7 @@ fun ClubDetail( introduction = state.clubDetails?.introduction, modifier = Modifier .fillMaxSize() - .verticalScroll(introductionScrollState, enabled = isIntroductionScrollable.value), + .verticalScroll(introductionScrollState), onFixIntroClick = { scope.launch { val result = snackbarHostState.showSnackbar( @@ -781,7 +855,7 @@ fun ClubDetail( ClubDetailRecruit( modifier = Modifier .fillMaxSize() - .verticalScroll(recruitScrollState, enabled = isRecruitScrollable.value), + .verticalScroll(recruitScrollState), recruitment = state.clubRecruitment, showProgressBar = state.showRecruitProgressBar, onImageClick = viewModel::showImageDialog, @@ -795,11 +869,10 @@ fun ClubDetail( if (state.clubEventSelected && state.selectedEventIndex != -1) { val selectedEvent = state.clubEvents[state.selectedEventIndex] val eventInfoScrollState = rememberScrollState() - val isEventInfoScrollable = remember { derivedStateOf { !listState.canScrollForward || eventInfoScrollState.value != 0 } } ClubDetailEventInfo( modifier = Modifier .fillMaxSize() - .verticalScroll(eventInfoScrollState, enabled = isEventInfoScrollable.value), + .verticalScroll(eventInfoScrollState), clubEvent = selectedEvent, onBackPressed = viewModel::deselectEvent, onEventDeleteClick = { @@ -817,7 +890,7 @@ fun ClubDetail( ClubDetailEvents( modifier = Modifier .fillMaxSize() - .verticalScroll(eventsScrollState, enabled = isEventsScrollable.value), + .verticalScroll(eventsScrollState), isDropdownExpanded = state.isEventsDropdownExpanded, clubEvents = state.clubEvents, onDropdownExpandChange = viewModel::updateEventsDropdownExpanded, @@ -837,7 +910,7 @@ fun ClubDetail( ClubDetailQna( modifier = Modifier .fillMaxSize() - .verticalScroll(qnaScrollState, enabled = isQnaScrollable.value), + .verticalScroll(qnaScrollState), qnaList = qnaList, isManager = state.clubDetails?.manager ?: false, userId = state.userId, diff --git a/feature/club/src/main/java/in/koreatech/koin/feature/club/ui/clubdetail/scroll/ClubDetailScrollConnection.kt b/feature/club/src/main/java/in/koreatech/koin/feature/club/ui/clubdetail/scroll/ClubDetailScrollConnection.kt new file mode 100644 index 000000000..39636413c --- /dev/null +++ b/feature/club/src/main/java/in/koreatech/koin/feature/club/ui/clubdetail/scroll/ClubDetailScrollConnection.kt @@ -0,0 +1,31 @@ +package `in`.koreatech.koin.feature.club.ui.clubdetail.scroll + +import androidx.compose.foundation.ScrollState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource + +fun clubDetailScrollConnection( + currentScrollState: State, + toolbarOffsetPx: MutableState, + maxToolbarHeightPx: Float, + minToolbarHeightPx: Float +) = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + var delta = available.y + val scrollState = currentScrollState.value + if (delta > 0) { + if (scrollState.value <= delta) { + delta += scrollState.value + } else { + delta = 0f + } + } + val newOffset = toolbarOffsetPx.value + delta + val beforeToolbarOffsetPx = toolbarOffsetPx.value + toolbarOffsetPx.value = newOffset.coerceIn(-(maxToolbarHeightPx - minToolbarHeightPx), 0f) + return Offset(0f, toolbarOffsetPx.value - beforeToolbarOffsetPx) + } +}