diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt index a80d71a..773b642 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapFragment.kt @@ -3,18 +3,23 @@ package com.daedan.festabook.presentation.placeMap import android.content.Context import android.os.Bundle import android.view.View -import android.widget.AdapterView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.commit import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentPlaceMapBinding import com.daedan.festabook.di.fragment.FragmentKey import com.daedan.festabook.di.mapManager.MapManagerGraph -import com.daedan.festabook.domain.model.TimeTag import com.daedan.festabook.logging.logger import com.daedan.festabook.presentation.common.BaseFragment import com.daedan.festabook.presentation.common.OnMenuItemReClickListener @@ -31,7 +36,9 @@ import com.daedan.festabook.presentation.placeMap.placeCategory.PlaceCategoryFra import com.daedan.festabook.presentation.placeMap.placeDetailPreview.PlaceDetailPreviewFragment import com.daedan.festabook.presentation.placeMap.placeDetailPreview.PlaceDetailPreviewSecondaryFragment import com.daedan.festabook.presentation.placeMap.placeList.PlaceListFragment -import com.daedan.festabook.presentation.placeMap.timeTagSpinner.adapter.TimeTagSpinnerAdapter +import com.daedan.festabook.presentation.placeMap.timeTagSpinner.component.TimeTagMenu +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme import com.naver.maps.map.MapFragment import com.naver.maps.map.NaverMap import com.naver.maps.map.OnMapReadyCallback @@ -57,8 +64,7 @@ class PlaceMapFragment( placeDetailPreviewSecondaryFragment: PlaceDetailPreviewSecondaryFragment, mapFragment: MapFragment, ) : BaseFragment(), - OnMenuItemReClickListener, - OnTimeTagSelectedListener { + OnMenuItemReClickListener { override val layoutId: Int = R.layout.fragment_place_map @Inject @@ -88,23 +94,6 @@ class PlaceMapFragment( savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - binding.spinnerSelectTimeTag.onItemSelectedListener = - object : AdapterView.OnItemSelectedListener { - override fun onItemSelected( - parent: AdapterView<*>, - view: View?, - position: Int, - id: Long, - ) { - val item = parent.getItemAtPosition(position) as TimeTag - - onTimeTagSelected(item) - } - - override fun onNothingSelected(parent: AdapterView<*>) { - onNothingSelected() - } - } if (savedInstanceState == null) { childFragmentManager.commit { addWithSimpleTag(R.id.fcv_map_container, mapFragment) @@ -118,6 +107,7 @@ class PlaceMapFragment( } lifecycleScope.launch { setUpMapManager() + setupComposeView() setUpObserver() } binding.logger.log( @@ -140,19 +130,6 @@ class PlaceMapFragment( mapManager?.moveToPosition() } - override fun onTimeTagSelected(item: TimeTag) { - viewModel.unselectPlace() - viewModel.onDaySelected(item) - binding.logger.log( - PlaceTimeTagSelected( - baseLogData = binding.logger.getBaseLogData(), - timeTagName = item.name, - ), - ) - } - - override fun onNothingSelected() = Unit - private suspend fun setUpMapManager() { naverMap = mapFragment.getMap() naverMap.addOnLocationChangeListener { @@ -169,22 +146,39 @@ class PlaceMapFragment( } } - private fun setUpObserver() { - viewModel.timeTags.observe(viewLifecycleOwner) { timeTags -> - // 타임태그가 없는 경우 메뉴 GONE - binding.layoutMapMenu.visibility = - if (timeTags.isNullOrEmpty()) View.GONE else View.VISIBLE - - if (binding.spinnerSelectTimeTag.adapter == null) { - val adapter = TimeTagSpinnerAdapter(requireContext(), timeTags.toMutableList()) - binding.spinnerSelectTimeTag.adapter = adapter - } else { - val adapter = binding.spinnerSelectTimeTag.adapter as TimeTagSpinnerAdapter - adapter.updateItems(timeTags) - adapter.notifyDataSetChanged() + private fun setupComposeView() { + binding.cvPlaceMap.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + FestabookTheme { + val timeTags by viewModel.timeTags.collectAsStateWithLifecycle() + val title by viewModel.selectedTimeTagFlow.collectAsStateWithLifecycle() + if (timeTags.isNotEmpty()) { + TimeTagMenu( + title = title.name, + timeTags = timeTags, + onTimeTagClick = { timeTag -> + viewModel.onDaySelected(timeTag) + binding.logger.log( + PlaceTimeTagSelected( + baseLogData = binding.logger.getBaseLogData(), + timeTagName = timeTag.name, + ), + ) + }, + modifier = + Modifier + .background( + FestabookColor.white, + ).padding(horizontal = 24.dp), + ) + } + } } } + } + private fun setUpObserver() { viewModel.placeGeographies.observe(viewLifecycleOwner) { placeGeographies -> when (placeGeographies) { is PlaceListUiState.Loading -> Unit diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt index 7836f60..0cf4241 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/PlaceMapViewModel.kt @@ -3,6 +3,7 @@ package com.daedan.festabook.presentation.placeMap import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey import com.daedan.festabook.domain.model.TimeTag @@ -21,6 +22,11 @@ import com.daedan.festabook.presentation.placeMap.model.toUiModel import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @ContributesIntoMap(AppScope::class) @@ -38,12 +44,19 @@ class PlaceMapViewModel @Inject constructor( val placeGeographies: LiveData>> get() = _placeGeographies - private val _timeTags = MutableLiveData>() - val timeTags: LiveData> = _timeTags + private val _timeTags = MutableStateFlow>(emptyList()) + val timeTags: StateFlow> = _timeTags.asStateFlow() private val _selectedTimeTag = MutableLiveData() val selectedTimeTag: LiveData = _selectedTimeTag + // 임시 StateFlow + val selectedTimeTagFlow: StateFlow = + _selectedTimeTag.asFlow().stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = TimeTag.EMPTY, + ) private val _selectedPlace: MutableLiveData = MutableLiveData() val selectedPlace: LiveData = _selectedPlace @@ -77,16 +90,17 @@ class PlaceMapViewModel @Inject constructor( _timeTags.value = emptyList() } - // 기본 선택값 - if (!timeTags.value.isNullOrEmpty()) { - _selectedTimeTag.value = _timeTags.value?.first() + // 기본 선택값 + if (!timeTags.value.isEmpty()) { + _selectedTimeTag.value = _timeTags.value.first() } else { - _selectedTimeTag.value = TimeTag.Companion.EMPTY + _selectedTimeTag.value = TimeTag.EMPTY } } } fun onDaySelected(item: TimeTag) { + unselectPlace() _selectedTimeTag.value = item } diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/adapter/TimeTagSpinnerAdapter.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/adapter/TimeTagSpinnerAdapter.kt deleted file mode 100644 index 2d21415..0000000 --- a/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/adapter/TimeTagSpinnerAdapter.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.daedan.festabook.presentation.placeMap.timeTagSpinner.adapter - -import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import com.daedan.festabook.databinding.ItemSpinnerDropdownBinding -import com.daedan.festabook.databinding.ItemSpinnerSelectedBinding -import com.daedan.festabook.domain.model.TimeTag - -class TimeTagSpinnerAdapter( - context: Context, - private val items: MutableList, -) : ArrayAdapter(context, 0, items) { - override fun getView( - position: Int, - convertView: View?, - parent: ViewGroup, - ): View { - val binding: ItemSpinnerSelectedBinding - val view: View - - if (convertView == null) { - binding = - ItemSpinnerSelectedBinding.inflate( - LayoutInflater.from(context), - parent, - false, - ) - view = binding.root - view.tag = binding - } else { - view = convertView - binding = view.tag as ItemSpinnerSelectedBinding - } - - binding.tvSelectedItem.text = items[position].name - return view - } - - override fun getDropDownView( - position: Int, - convertView: View?, - parent: ViewGroup, - ): View { - val binding: ItemSpinnerDropdownBinding - val view: View - - if (convertView == null) { - binding = - ItemSpinnerDropdownBinding.inflate( - LayoutInflater.from(context), - parent, - false, - ) - view = binding.root - view.tag = binding - } else { - view = convertView - binding = view.tag as ItemSpinnerDropdownBinding - } - - binding.tvDropdownItem.text = items[position].name - return view - } - - fun updateItems(newItems: List) { - items.clear() - items.addAll(newItems) - } -} diff --git a/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/component/TimeTagMenu.kt b/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/component/TimeTagMenu.kt new file mode 100644 index 0000000..657db70 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/placeMap/timeTagSpinner/component/TimeTagMenu.kt @@ -0,0 +1,184 @@ +package com.daedan.festabook.presentation.placeMap.timeTagSpinner.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuBoxScope +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.domain.model.TimeTag +import com.daedan.festabook.presentation.common.component.cardBackground +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.festabookShapes +import com.daedan.festabook.presentation.theme.festabookSpacing +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimeTagMenu( + title: String, + timeTags: List, + modifier: Modifier = Modifier, + onTimeTagClick: (TimeTag) -> Unit = {}, +) { + var expanded by remember { mutableStateOf(false) } + var dropdownWidth by remember { mutableStateOf(IntSize.Zero) } + val density = LocalDensity.current + val scope = rememberCoroutineScope() + + Row( + modifier = modifier.fillMaxWidth(), + ) { + ExposedDropdownMenuBox( + modifier = + Modifier + .wrapContentSize() + .background(Color.Transparent), + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + ) { + TimeTagButton( + title = title, + onSizeDetermined = { dropdownWidth = it }, + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + offset = DpOffset(x = 0.dp, y = festabookSpacing.paddingBody2), + modifier = + Modifier + .width( + with(density) { dropdownWidth.width.toDp() }, + ).cardBackground( + backgroundColor = FestabookColor.white, + borderStroke = 2.dp, + borderColor = FestabookColor.gray300, + shape = festabookShapes.radius2, + ), + shape = festabookShapes.radius2, + ) { + timeTags.forEach { item -> + DropdownMenuItem( + text = { + Text( + text = item.name, + style = MaterialTheme.typography.bodyLarge, + ) + }, + onClick = { + scope.launch { + onTimeTagClick(item) + waitForRipple { + expanded = false + } + } + }, + ) + } + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun ExposedDropdownMenuBoxScope.TimeTagButton( + title: String, + onSizeDetermined: (IntSize) -> Unit, +) { + Row( + modifier = + Modifier + .width(140.dp) + .onGloballyPositioned { coordinates -> + onSizeDetermined(coordinates.size) + }.menuAnchor( + type = MenuAnchorType.PrimaryNotEditable, + enabled = true, + ).height(TopAppBarDefaults.MediumAppBarCollapsedHeight) // Festabook TopAppbar Size + .background(Color.Transparent) + .clickable( + onClick = {}, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = title, + style = MaterialTheme.typography.displaySmall, + ) + + Icon( + painter = painterResource(id = R.drawable.ic_chevron_down), + contentDescription = stringResource(R.string.chevron_down), + ) + } +} + +private suspend inline fun waitForRipple( + timeMillis: Long = 100, + after: () -> Unit = {}, +) { + delay(timeMillis) + after() +} + +@Composable +@Preview(showBackground = true) +private fun TimeTagMenuPreview() { + val timeTags = + listOf( + TimeTag(1, "1일차 오전"), + TimeTag(2, "오후"), + ) + var title by remember { mutableStateOf("1일차 오전") } + FestabookTheme { + TimeTagMenu( + title = title, + timeTags = timeTags, + modifier = + Modifier + .background(FestabookColor.white) + .padding(horizontal = festabookSpacing.paddingScreenGutter), + // Festabook Gutter + onTimeTagClick = { }, + ) + } +} diff --git a/app/src/main/res/layout/fragment_place_map.xml b/app/src/main/res/layout/fragment_place_map.xml index e1d7e7f..c2df411 100644 --- a/app/src/main/res/layout/fragment_place_map.xml +++ b/app/src/main/res/layout/fragment_place_map.xml @@ -1,17 +1,15 @@ - - - - - - - - - + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintTop_toBottomOf="@id/cv_place_map" />