From 46d71bda3ebdd2e90d35a49b40963feb6d0c5d86 Mon Sep 17 00:00:00 2001 From: KYM-P Date: Tue, 28 Oct 2025 20:37:41 +0900 Subject: [PATCH 1/7] feature: Add club scroll test Add club scroll test --- .../feature/club/ui/clubdetail/ClubDetail.kt | 548 ++++++++++-------- .../scroll/ClubDetailScrollConnection.kt | 31 + 2 files changed, 322 insertions(+), 257 deletions(-) create mode 100644 feature/club/src/main/java/in/koreatech/koin/feature/club/ui/clubdetail/scroll/ClubDetailScrollConnection.kt 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..901736292 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,9 @@ package `in`.koreatech.koin.feature.club.ui.clubdetail import android.content.Context import android.content.Intent import android.net.Uri +import android.util.Log +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 @@ -17,12 +20,11 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding 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,16 +45,17 @@ 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.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned 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 @@ -61,6 +64,7 @@ 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 +113,7 @@ 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.coroutines.launch import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect @@ -152,24 +157,54 @@ 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 -> recruitScrollState + DetailTabType.EVENT.strResId -> eventsScrollState + DetailTabType.RECRUIT.strResId -> qnaScrollState + 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) + ) + Log.e("MYLOG","${animatedToolbarHeight} ${maxToolbarHeight}") + viewModel.collectSideEffect { sideEffect -> handleSideEffect(sideEffect, context, snackbarHostState) } @@ -193,7 +228,7 @@ fun ClubDetail( LaunchedEffect(isRecruitEvent) { if (isRecruitEvent) { pagerState.animateScrollToPage(1) - listState.animateScrollToItem(2) + toolbarOffsetPx.floatValue = minToolbarHeightPx } } @@ -203,7 +238,7 @@ fun ClubDetail( if (eventIndex != -1) { viewModel.selectEvent(eventIndex) pagerState.animateScrollToPage(2) - listState.animateScrollToItem(2) + toolbarOffsetPx.floatValue = minToolbarHeightPx resetNorificationEventId() } } @@ -225,7 +260,7 @@ fun ClubDetail( shape = CircleShape, onClick = { scope.launch { - listState.animateScrollToItem(0) + toolbarOffsetPx.floatValue = maxToolbarHeightPx qnaScrollState.scrollTo(0) } }, @@ -428,16 +463,27 @@ fun ClubDetail( ) } - LazyColumn( + Box( modifier = Modifier .padding(contentPadding) .consumeWindowInsets(contentPadding) .systemBarsPadding() - .fillMaxSize(), - state = listState, - horizontalAlignment = Alignment.CenterHorizontally + .fillMaxSize() + .nestedScroll(nestedScrollConnection) ) { - item { + Column( + modifier = Modifier + .onGloballyPositioned { + maxToolbarHeight = with(density) { it.size.height.toDp() } + } + .fillMaxWidth() + .offset(y = -(maxToolbarHeight - animatedToolbarHeight)) + .padding( + horizontal = 24.dp, + vertical = 16.dp + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { SubcomposeAsyncImage( modifier = Modifier .size(200.dp) @@ -466,266 +512,258 @@ fun ClubDetail( } } ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = 24.dp, - vertical = 16.dp - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - 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) - ) - 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) - ) - } + 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) + ) + 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 = Modifier.height(16.dp)) + } + 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.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( - 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( - painter = if (state.clubDetails?.isLiked == true) painterResource(id = R.drawable.icon_like_true) else painterResource(id = R.drawable.icon_like_false), - contentDescription = "", + state.clubDetails?.hotStatus?.let { + Row( modifier = Modifier - .size(24.dp) - .padding(end = 4.dp) - .clickable { - state.userId?.let { - viewModel.changeClubLike() - } ?: viewModel.showLoginDialog() - } - ) - if (state.clubDetails?.isLikeHidden != true) { + .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 = "${state.clubDetails?.likes}", - style = KoinTheme.typography.medium14 + 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( + painter = if (state.clubDetails?.isLiked == true) painterResource(id = R.drawable.icon_like_true) else painterResource(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}", + 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)) } - } - 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 + 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)) } } - if ( - it.isValidGoogleFormUrl() || - it.isValidOpenChatUrl() - ) { - linkUrl = it - onClick = { if (linkUrl.isNotEmpty()) viewModel.openUrl(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 } - Row( - verticalAlignment = Alignment.CenterVertically + if ( + it.isValidGoogleFormUrl() || + it.isValidOpenChatUrl() ) { - 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( - painter = painterResource(id = icon), - contentDescription = "Phone Number Copy Icon", - modifier = Modifier - .size(24.dp) - .padding(end = 4.dp) - .clickable { - onIconClick() - } - ) - } + linkUrl = it + onClick = { if (linkUrl.isNotEmpty()) viewModel.openUrl(linkUrl) } } } - 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 - ) + 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) { 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", + painter = painterResource(id = icon), + contentDescription = "Phone Number Copy 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() + onIconClick() } ) } } } - 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 - ) + 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( + 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 { + + Column( + modifier = Modifier.fillMaxSize() + ) { + Spacer(Modifier.height(animatedToolbarHeight)) + DetailTabRow( - modifier = Modifier.zIndex(2f).onGloballyPositioned { - tabRowHeight = with(density) { - it.size.height.toDp() - } - }, + modifier = Modifier.zIndex(2f), selectedTabIndex = pagerState.currentPage, onTabSelected = { EventLogger.logCampusClickEvent( @@ -738,13 +776,10 @@ fun ClubDetail( }, titles = tabList.map { stringResource(it) } ) - } - item { - val deviceHeightDp = LocalConfiguration.current.screenHeightDp.dp - (contentPadding.calculateTopPadding().value.dp + 24.dp) - tabRowHeight + HorizontalPager( modifier = Modifier - .fillMaxSize() - .height(deviceHeightDp), + .fillMaxSize(), state = pagerState, verticalAlignment = Alignment.Top ) { page -> @@ -756,7 +791,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 +816,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 +830,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 +851,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 +871,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..27d3be3ea --- /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 + if (available.y > 0) { + val scrollState = currentScrollState.value + if (scrollState.value <= delta) { + delta -= scrollState.value + } else { + return Offset.Zero + } + } + val newOffset = toolbarOffsetPx.value + delta + val beforeToolbarOffsetPx = toolbarOffsetPx.value + toolbarOffsetPx.value = newOffset.coerceIn(-(maxToolbarHeightPx - minToolbarHeightPx), 0f) + return Offset(0f, toolbarOffsetPx.value - beforeToolbarOffsetPx) + } +} From 68341f289ac56344773103e29831a19fa618c890 Mon Sep 17 00:00:00 2001 From: KYM-P Date: Wed, 29 Oct 2025 14:33:13 +0900 Subject: [PATCH 2/7] fix: Change scroll event Change scroll event --- .../feature/club/ui/clubdetail/ClubDetail.kt | 558 +++++++++--------- .../scroll/ClubDetailScrollConnection.kt | 9 +- 2 files changed, 299 insertions(+), 268 deletions(-) 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 901736292..e8610c1d6 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 @@ -20,11 +20,13 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding 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.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState @@ -41,6 +43,8 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -55,6 +59,7 @@ import androidx.compose.ui.Modifier 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.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -174,9 +179,9 @@ fun ClubDetail( derivedStateOf { when (tabList[pagerState.currentPage]) { DetailTabType.DETAIL_INTRO.strResId -> introductionScrollState - DetailTabType.QNA.strResId -> recruitScrollState + DetailTabType.QNA.strResId -> qnaScrollState DetailTabType.EVENT.strResId -> eventsScrollState - DetailTabType.RECRUIT.strResId -> qnaScrollState + DetailTabType.RECRUIT.strResId -> recruitScrollState else -> introductionScrollState } } @@ -203,7 +208,6 @@ fun ClubDetail( targetValue = toolbarHeight, animationSpec = tween(durationMillis = 50) ) - Log.e("MYLOG","${animatedToolbarHeight} ${maxToolbarHeight}") viewModel.collectSideEffect { sideEffect -> handleSideEffect(sideEffect, context, snackbarHostState) @@ -463,7 +467,7 @@ fun ClubDetail( ) } - Box( + Column( modifier = Modifier .padding(contentPadding) .consumeWindowInsets(contentPadding) @@ -471,288 +475,330 @@ fun ClubDetail( .fillMaxSize() .nestedScroll(nestedScrollConnection) ) { - Column( + /* + * LazyColumn can detect scroll gestures in nestedScrollConnection + * With 'reverseLayout = true', items can disappear starting from the top + */ + LazyColumn( modifier = Modifier - .onGloballyPositioned { - maxToolbarHeight = with(density) { it.size.height.toDp() } - } .fillMaxWidth() - .offset(y = -(maxToolbarHeight - animatedToolbarHeight)) - .padding( - horizontal = 24.dp, - vertical = 16.dp - ), - horizontalAlignment = Alignment.CenterHorizontally - ) { - 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)) - } - } - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - 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) - ) - 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) - ) + .then( + if(maxToolbarHeight.value == 0f) { + Modifier.onSizeChanged{ size -> + maxToolbarHeight = with(density) { size.height.toDp() } } } - } - } - 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, + else { + Modifier.height(animatedToolbarHeight) + } + ), + reverseLayout = true + ) { + item { + /* + * Column in LazyColumn Because of `reverseLayout = true` option + */ + Column( modifier = Modifier - .padding(end = 16.dp) - .weight(1f, fill = false), - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Row( - verticalAlignment = Alignment.CenterVertically + .fillMaxWidth() + .padding( + horizontal = 24.dp, + vertical = 16.dp + ), + horizontalAlignment = Alignment.CenterHorizontally ) { - 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( - painter = if (state.clubDetails?.isLiked == true) painterResource(id = R.drawable.icon_like_true) else painterResource(id = R.drawable.icon_like_false), - contentDescription = "", + SubcomposeAsyncImage( modifier = Modifier - .size(24.dp) - .padding(end = 4.dp) + .size(200.dp) .clickable { - state.userId?.let { - viewModel.changeClubLike() - } ?: viewModel.showLoginDialog() - } - ) - if (state.clubDetails?.isLikeHidden != true) { - Text( - text = "${state.clubDetails?.likes}", - style = KoinTheme.typography.medium14 - ) - } - } - } - 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)) } + 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() } - DETAIL_PHONE_NUMBER -> { - outputText = if (it.isValidPhoneNumber) it.formatPhoneNumber() else it - icon = R.drawable.icon_club_copy - onIconClick = { clipboard.setText(AnnotatedString(it)) } + }, + error = { + Box( + contentAlignment = Alignment.Center + ) { + Text(stringResource(R.string.detail_club_image_error)) } - else -> outputText = it } - if ( - it.isValidGoogleFormUrl() || - it.isValidOpenChatUrl() - ) { - linkUrl = it - onClick = { if (linkUrl.isNotEmpty()) viewModel.openUrl(linkUrl) } + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + 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) + ) + 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 = Modifier.height(16.dp)) Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, 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) + text = state.clubDetails?.name ?: "", + style = KoinTheme.typography.bold20, + modifier = Modifier + .padding(end = 16.dp) + .weight(1f, fill = false), + maxLines = 2, + overflow = TextOverflow.Ellipsis ) - if (icon != -1) { + 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( - painter = painterResource(id = icon), - contentDescription = "Phone Number Copy Icon", + painter = if (state.clubDetails?.isLiked == true) painterResource(id = R.drawable.icon_like_true) else painterResource(id = R.drawable.icon_like_false), + contentDescription = "", modifier = Modifier .size(24.dp) .padding(end = 4.dp) .clickable { - onIconClick() + state.userId?.let { + viewModel.changeClubLike() + } ?: viewModel.showLoginDialog() } ) + if (state.clubDetails?.isLikeHidden != true) { + Text( + text = "${state.clubDetails?.likes}", + style = KoinTheme.typography.medium14 + ) + } } } - } - if (state.clubDetails?.manager == false) { + 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)) } + } + 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 + } + if ( + it.isValidGoogleFormUrl() || + it.isValidOpenChatUrl() + ) { + linkUrl = it + onClick = { if (linkUrl.isNotEmpty()) viewModel.openUrl(linkUrl) } + } + } + 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) { + Spacer(Modifier.width(8.dp)) + Image( + painter = painterResource(id = icon), + contentDescription = "Phone Number Copy Icon", + modifier = Modifier + .size(24.dp) + .padding(end = 4.dp) + .clickable { + onIconClick() + } + ) + } + } + } + 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 ) { - 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", + painter = painterResource(id = R.drawable.icon_club_info), + contentDescription = "picture", modifier = Modifier - .size(24.dp) + .size(17.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() - } + ) + 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 + } + /* + * 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) } ) } } @@ -760,22 +806,6 @@ fun ClubDetail( Column( modifier = Modifier.fillMaxSize() ) { - Spacer(Modifier.height(animatedToolbarHeight)) - - 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) } - ) HorizontalPager( modifier = Modifier 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 index 27d3be3ea..41c673c9c 100644 --- 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 @@ -1,5 +1,6 @@ package `in`.koreatech.koin.feature.club.ui.clubdetail.scroll +import android.util.Log import androidx.compose.foundation.ScrollState import androidx.compose.runtime.MutableState import androidx.compose.runtime.State @@ -15,12 +16,12 @@ fun clubDetailScrollConnection( ) = object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { var delta = available.y - if (available.y > 0) { - val scrollState = currentScrollState.value + val scrollState = currentScrollState.value + if (delta > 0) { if (scrollState.value <= delta) { - delta -= scrollState.value + delta += scrollState.value } else { - return Offset.Zero + delta = 0f } } val newOffset = toolbarOffsetPx.value + delta From 3d6644fe3c0def340debed8bbdd51fbcea4552a4 Mon Sep 17 00:00:00 2001 From: KYM-P Date: Wed, 29 Oct 2025 14:41:33 +0900 Subject: [PATCH 3/7] chore: Feat ktlint Feat ktlint --- .../feature/club/ui/clubdetail/ClubDetail.kt | 17 +++++------------ .../scroll/ClubDetailScrollConnection.kt | 1 - 2 files changed, 5 insertions(+), 13 deletions(-) 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 e8610c1d6..ce8a5a64c 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,7 +3,6 @@ package `in`.koreatech.koin.feature.club.ui.clubdetail import android.content.Context import android.content.Intent import android.net.Uri -import android.util.Log import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi @@ -20,8 +19,6 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding @@ -43,8 +40,6 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -58,7 +53,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.LocalContext @@ -201,8 +195,9 @@ fun ClubDetail( minToolbarHeight, if (maxToolbarHeightPx != minToolbarHeightPx) { -toolbarOffsetPx.floatValue / (maxToolbarHeightPx - minToolbarHeightPx) + } else { + 0f } - else 0f ) val animatedToolbarHeight by animateDpAsState( targetValue = toolbarHeight, @@ -483,12 +478,11 @@ fun ClubDetail( modifier = Modifier .fillMaxWidth() .then( - if(maxToolbarHeight.value == 0f) { - Modifier.onSizeChanged{ size -> + if (maxToolbarHeight.value == 0f) { + Modifier.onSizeChanged { size -> maxToolbarHeight = with(density) { size.height.toDp() } } - } - else { + } else { Modifier.height(animatedToolbarHeight) } ), @@ -806,7 +800,6 @@ fun ClubDetail( Column( modifier = Modifier.fillMaxSize() ) { - HorizontalPager( modifier = Modifier .fillMaxSize(), 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 index 41c673c9c..39636413c 100644 --- 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 @@ -1,6 +1,5 @@ package `in`.koreatech.koin.feature.club.ui.clubdetail.scroll -import android.util.Log import androidx.compose.foundation.ScrollState import androidx.compose.runtime.MutableState import androidx.compose.runtime.State From c88c067469a705129f80a27cf768862bacd5dcb0 Mon Sep 17 00:00:00 2001 From: KYM-P Date: Wed, 29 Oct 2025 15:15:46 +0900 Subject: [PATCH 4/7] fix: Fix scroll event to handle new scroll logic Fix scroll event to handle new scroll logic --- .../feature/club/ui/clubdetail/ClubDetail.kt | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) 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 ce8a5a64c..dc49f2923 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 @@ -204,11 +204,20 @@ fun ClubDetail( 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() } @@ -227,7 +236,7 @@ fun ClubDetail( LaunchedEffect(isRecruitEvent) { if (isRecruitEvent) { pagerState.animateScrollToPage(1) - toolbarOffsetPx.floatValue = minToolbarHeightPx + toolBarFullScrollEvent = true } } @@ -237,7 +246,7 @@ fun ClubDetail( if (eventIndex != -1) { viewModel.selectEvent(eventIndex) pagerState.animateScrollToPage(2) - toolbarOffsetPx.floatValue = minToolbarHeightPx + toolBarFullScrollEvent = true resetNorificationEventId() } } @@ -259,8 +268,8 @@ fun ClubDetail( shape = CircleShape, onClick = { scope.launch { - toolbarOffsetPx.floatValue = maxToolbarHeightPx - qnaScrollState.scrollTo(0) + toolbarOffsetPx.floatValue = minToolbarHeightPx + currentScrollState.value.scrollTo(0) } }, elevation = FloatingActionButtonDefaults.elevation( From a91f33c1787e54ff2f7f2280eb79fd28a3906623 Mon Sep 17 00:00:00 2001 From: KYM-P Date: Wed, 29 Oct 2025 15:43:45 +0900 Subject: [PATCH 5/7] chore: Feat ktlint Feat ktlint --- .../in/koreatech/koin/feature/club/ui/clubdetail/ClubDetail.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 dc49f2923..2f151b965 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 @@ -207,7 +207,7 @@ fun ClubDetail( var toolBarFullScrollEvent by remember { mutableStateOf(false) } LaunchedEffect(toolBarFullScrollEvent, maxToolbarHeight) { - if(maxToolbarHeight.value != 0f && toolBarFullScrollEvent) { + if (maxToolbarHeight.value != 0f && toolBarFullScrollEvent) { toolbarOffsetPx.floatValue = -maxToolbarHeightPx toolBarFullScrollEvent = false } From eaeb16c6b57d2b3bdace61987c4273c838422a79 Mon Sep 17 00:00:00 2001 From: KYM-P Date: Fri, 14 Nov 2025 00:14:02 +0900 Subject: [PATCH 6/7] chore: Delete unremember variable to skip recomposition Delete unremember variable to skip recomposition --- .../feature/club/ui/clubdetail/ClubDetail.kt | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) 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 2f151b965..f80ce384f 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 @@ -51,6 +51,7 @@ 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.onSizeChanged @@ -59,6 +60,7 @@ 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 @@ -146,8 +148,8 @@ fun ClubDetail( 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 } + 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() @@ -617,7 +619,7 @@ fun ClubDetail( } 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), + 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) @@ -630,7 +632,7 @@ fun ClubDetail( ) if (state.clubDetails?.isLikeHidden != true) { Text( - text = "${state.clubDetails?.likes}", + text = state.clubDetails?.likes.toString(), style = KoinTheme.typography.medium14 ) } @@ -644,12 +646,12 @@ fun ClubDetail( ) { detailList.forEach { intro -> if (intro.second.isNullOrBlank()) return@forEach - var outputText = "" - var linkUrl = "" + var outputText by remember { mutableStateOf("") } + var linkUrl by remember { mutableStateOf("") } val showMore = remember { mutableStateOf(false) } - var icon = -1 - var onClick = {} - var onIconClick = {} + 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) { @@ -708,13 +710,15 @@ fun ClubDetail( 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) + modifier = Modifier + .clickable { onClick() } + .weight(1f, fill = false) ) if (icon != -1) { Spacer(Modifier.width(8.dp)) Image( - painter = painterResource(id = icon), - contentDescription = "Phone Number Copy Icon", + imageVector = ImageVector.vectorResource(id = icon), + contentDescription = "Copy Icon", modifier = Modifier .size(24.dp) .padding(end = 4.dp) @@ -736,10 +740,10 @@ fun ClubDetail( ) Spacer(Modifier.width(8.dp)) Image( - painter = if (state.clubDetails?.isRecruitSubscribed == true) { - painterResource(R.drawable.icon_notification_true) + imageVector = if (state.clubDetails?.isRecruitSubscribed == true) { + ImageVector.vectorResource(R.drawable.icon_notification_true) } else { - painterResource(R.drawable.icon_notification_false) + ImageVector.vectorResource(R.drawable.icon_notification_false) }, contentDescription = "Notification Icon", modifier = Modifier @@ -764,7 +768,7 @@ fun ClubDetail( verticalAlignment = Alignment.CenterVertically ) { Image( - painter = painterResource(id = R.drawable.icon_club_info), + imageVector = ImageVector.vectorResource(id = R.drawable.icon_club_info), contentDescription = "picture", modifier = Modifier .size(17.dp) From 1913b16e7e9e8587603134a6d1abdfd582e1d3fa Mon Sep 17 00:00:00 2001 From: KYM-P Date: Fri, 14 Nov 2025 17:08:46 +0900 Subject: [PATCH 7/7] chore: Change listof variable to remember persistentlist Change listof variable to remember persistentlist --- .../feature/club/ui/clubdetail/ClubDetail.kt | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) 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 f80ce384f..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 @@ -115,6 +115,7 @@ 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 @@ -139,15 +140,17 @@ 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 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 }) }