[Kotlin / Compose] 이미지 검색 Migration

Subeen·2024년 5월 13일
0

Compose

목록 보기
12/20

이전에 구현한 Kakao API 이미지 검색 앱을 Jetpack Compose로 변환하는 작업을 진행하였다.

Compose Migration

SearchListScreen

SearchListScreen 함수는 전체 검색 화면을 나타낸다.
LiveDataComposable에서 사용하기 위해 observeAsState() 함수를 사용하여 현재 검색 상태를 관찰한다.
Scaffold를 사용하여 화면의 레이아웃을 구성하며 상단 바에는 SearchTopBar를 표시하고, 검색 결과는 SearchList로 표시된다.
LaunchedEffect에서는 검색어에 따라 결과를 다시 로드한다.

@SuppressLint("UnusedMaterialScaffoldPaddingParameter")
@Composable
fun SearchListScreen(viewModel: SearchViewModel = hiltViewModel()) {
    // ViewModel의 searchResult 상태를 관찰
    val searchState by viewModel.searchResult.observeAsState()

    // Scaffold는 화면의 기본 레이아웃 구조를 제공
    Scaffold(
        topBar = {
            // 검색 상단 바를 정의
            SearchTopBar(
                // 검색 버튼 클릭 시 호출되는 함수
                onSearch = { newQuery ->
                    viewModel.resetPageCount() // 페이지 카운트를 초기화
                    viewModel.saveStorageSearchWord(newQuery) // 검색어를 저장
                    viewModel.searchCombinedResults(newQuery) // 새로운 검색을 실행
                },
                query = searchState?.keyword ?: "", // 현재 검색어를 표시
                onQueryChange = { _ -> } // 검색어 변경 시 호출되는 함수(현재 빈 함수)
            )
        },
        content = {
            // searchState가 null이 아닌 경우
            if (searchState != null) {
                // 검색 결과 목록을 표시
                SearchList(
                    items = searchState!!.list, // 검색 결과 항목
                    onItemClick = { viewModel.updateStorageItem(it) }, // 항목 클릭 시 호출
                    onScrollEnd = { viewModel.plusPageCount() } // 스크롤 끝에 도달 시 호출
                )
            } else {
                // 검색 결과가 없을 때 로딩 인디케이터 표시
                Box(
                    contentAlignment = Alignment.Center, // 가운데 정렬
                    modifier = Modifier.fillMaxSize() // 화면 전체 크기
                ) {
                    CircularProgressIndicator(color = colorResource(id = R.color.color_1)) // 로딩 인디케이터
                }
            }
        }
    )

    // 현재 검색어를 변수에 저장
    val currentKeyword = searchState?.keyword ?: ""

    // 검색어가 변경될 때마다 실행
    LaunchedEffect(currentKeyword) {
        viewModel.reloadStorageItems() // 저장된 항목을 다시 로드
        viewModel.searchCombinedResults(currentKeyword) // 현재 검색어로 검색 실행
    }
}

SearchTopBar

SearchTopBar 함수는 검색 화면의 상단 바를 나타낸다.
검색 아이콘을 클릭하면 onSearch 콜백이 호출되고, 검색어가 변경되면 onQueryChange 콜백이 호출된다.

@Composable
fun SearchTopBar(
    onSearch: (String) -> Unit, // 검색 실행 콜백 함수
    query: String, // 현재 검색어
    onQueryChange: (String) -> Unit // 검색어 변경 콜백 함수
) {
    /*
     * 현재 텍스트 필드 값
     * 현재의 검색어를 가지고 있는 text 상태를 생성하고 이를 Composable 함수 내에서 관리
     */
    var text by remember { mutableStateOf(TextFieldValue(query)) }
    val gradient = listOf(
        colorResource(id = R.color.color_1),
        colorResource(id = R.color.color_2),
        colorResource(id = R.color.color_3)
    )

    val gradientLine =  listOf(
        colorResource(id = R.color.white),
        colorResource(id = R.color.color_3)
    )

    Column(
        modifier = Modifier.fillMaxWidth() // 수직 컬럼
    ) {
        // 로고 이미지와 검색창
        Row(verticalAlignment = Alignment.CenterVertically) {
            Image(
                painter = painterResource(id = R.drawable.ic_symbol),
                contentDescription = null,
                modifier = Modifier
                    .width(100.dp)
                    .padding(start = 16.dp)
            )
            // 검색창
            Row(
                modifier = Modifier
                    .height(48.dp) // 검색창 높이
                    .padding(horizontal = 16.dp)
                    .border(
                        BorderStroke(
                            width = 1.dp, // 테두리 두께
                            brush = Brush.linearGradient(
                                colors = gradient, // 그라데이션 색상
                                start = Offset.Zero,
                                end = Offset.Infinite
                            )
                        ),
                        shape = RoundedCornerShape(50) // 테두리 모양
                    ),
                verticalAlignment = Alignment.CenterVertically
            ) {
                // 텍스트 입력 필드
                TextField(
                    value = text,
                    onValueChange = {
                        text = it
                        onQueryChange(it.text) // 검색어 변경 콜백 호출
                    },
                    modifier = Modifier
                        .weight(1f) // 가중치 1
                        .padding(start = 8.dp),
                    colors = TextFieldDefaults.textFieldColors(
                        backgroundColor = Color.Transparent,
                        focusedIndicatorColor = Color.Transparent,
                        unfocusedIndicatorColor = Color.Transparent
                    ),
                    textStyle = TextStyle.Default
                )

                // 검색 아이콘 버튼
                Box(
                    modifier = Modifier
                        .size(32.dp)
                        .padding(end = 12.dp)
                        .clickable {
                            onSearch(text.text)
                        }, // 클릭 가능
                ) {
                    Image(
                        painter = painterResource(id = R.drawable.ic_tab_search),
                        contentDescription = null,
                        modifier = Modifier.fillMaxSize()
                    )
                }
            }
        }

        // 그라데이션 라인
        Box(
            modifier = Modifier
                .padding(vertical = 8.dp)
                .fillMaxWidth()
                .height(4.dp)
                .alpha(0.3f)
        ) {
            Canvas(modifier = Modifier.matchParentSize()) {
                drawRect( // 사각형을 그리는 데 사용되며 그라데이션 라인을 나타냄
                    brush = Brush.verticalGradient( // 그라데이션 효과를 적용
                        colors = gradientLine, // 그라데이션 라인 색상
                        startY = 0f, // 그라데이션의 상단을 의미
                        endY = size.height // 그라데이션의 하단을 의미
                    ),
                    size = Size(size.width, size.height)
                )
            }
        }
    }
}

SearchList

SearchList 함수는 검색 결과를 그리드 형태로 표시하는 역할을 한다.
LazyVerticalGrid를 사용하여 세로 방향의 그리드를 구성하고, 각 항목은 SearchItem으로 표시된다.
스크롤이 끝에 도달하면 onScrollEnd 콜백이 호출된다.

@Composable
fun SearchList(
    items: List<SearchModel>, // 검색 항목 목록
    onItemClick: (SearchModel) -> Unit, // 항목을 클릭할 때 호출되는 콜백 함수
    onScrollEnd: () -> Unit // 스크롤이 끝에 도달했을 때 호출되는 콜백 함수
) {
    LazyVerticalGrid( // 세로 방향의 그리드 형태로 아이템을 배치하는 LazyVerticalGrid
        columns = GridCells.Fixed(2), // 고정된 열 개수
        contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp) // 아이템 간의 수직 간격
    ) {
        items(items.size) { index ->
            SearchItem( // 검색 항목을 표시하는 SearchItem 컴포저블
                item = items[index], // 현재 인덱스에 해당하는 항목
                onItemClick = onItemClick, // 항목을 클릭할 때 호출되는 콜백 함수 전달
                modifier = Modifier.fillMaxWidth() // 너비를 최대한 채우도록 수정자 지정
            )
        }

        item {
            onScrollEnd() // 스크롤이 끝에 도달했을 때 호출되는 콜백 함수 실행
        }

    }
}

SearchItem

SearchItem 함수는 검색 결과 목록에서 각 항목을 표시하는 역할을 한다.
각 항목은 Card 형태로 표시된다.

@Composable
fun SearchItem(
    item: SearchModel, // 검색 항목을 나타내는 데이터 모델
    onItemClick: (SearchModel) -> Unit, // 항목을 클릭했을 때 호출되는 콜백 함수
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier.padding(12.dp) // 세로 방향의 컬럼 레이아웃과 패딩
    ) {
        Card(
            modifier = modifier.clickable { onItemClick(item) }, // 카드 전체에 클릭 가능 영역 설정
            shape = RoundedCornerShape(16.dp) // 카드의 모서리를 둥글게 만듦
        ) {
            ConstraintLayout( // 자식 뷰의 위치를 제약 조건으로 지정하는 ConstraintLayout
                modifier = Modifier.fillMaxSize()
            ) {
                val (imageView, heartImageView, itemTypeText, dateTimeText) = createRefs()

                // 비동기 이미지 로딩 컴포넌트
                AsyncImage(
                    model = item.thumbnailUrl ?: "", // 이미지 URL, 기본값은 빈 문자열
                    contentDescription = null,
                    contentScale = ContentScale.FillWidth, // 이미지 콘텐츠의 스케일 타입
                    modifier = Modifier
                        .constrainAs(imageView) {
                            top.linkTo(parent.top) // 상단 가장자리를 부모의 상단 가장자리에 링크
                            start.linkTo(parent.start) // 시작 가장자리를 부모의 시작 가장자리에 링크
                            end.linkTo(parent.end) // 끝 가장자리를 부모의 끝 가장자리에 링크
                        }
                        .fillMaxWidth()
                )

                Box(
                    modifier = Modifier
                        .padding(8.dp)
                        .constrainAs(itemTypeText) {
                            top.linkTo(imageView.top) // 상단 가장자리를 이미지 뷰의 상단 가장자리에 링크
                            start.linkTo(imageView.start) // 시작 가장자리를 이미지 뷰의 시작 가장자리에 링크
                        }
                        .background( // 선형 그라데이션 배경
                            brush = Brush.linearGradient(
                                colors = listOf(
                                    colorResource(id = R.color.color_1),
                                    colorResource(id = R.color.color_2),
                                    colorResource(id = R.color.color_3)
                                ),
                                start = Offset.Zero,
                                end = Offset.Infinite
                            ),
                            shape = RoundedCornerShape(8.dp) // 배경의 둥근 모서리
                        ),
                    contentAlignment = Alignment.Center
                ) {
                    // 텍스트 표시
                    Text(
                        text = item.itemType.name,
                        color = Color.White,
                        fontSize = 11.sp,
                        fontWeight = FontWeight.Bold,
                        textAlign = TextAlign.Center,
                        modifier = Modifier.padding(horizontal = 8.dp, vertical = 1.dp)
                    )
                }

                // 날짜와 시간을 표시하는 텍스트
                Text(
                    text = formatDate(item.datetime), // 형식화된 날짜와 시간 문자열
                    fontSize = 12.sp,
                    color = Color(android.graphics.Color.parseColor("#c8c8c8")),
                    modifier = Modifier
                        .constrainAs(dateTimeText) {
                            start.linkTo(imageView.start) // 시작 가장자리를 이미지 뷰의 시작 가장자리에 링크
                            end.linkTo(imageView.end) // 끝 가장자리를 이미지 뷰의 끝 가장자리에 링크
                            bottom.linkTo(imageView.bottom) // 하단 가장자리를 이미지 뷰의 하단 가장자리에 링크
                        }
                        .fillMaxWidth()
                        .background(
                            // copy 함수를 사용하여 기존의 Color 객체를 변경하지 않고 새로운 Color 객체 생성
                            color = Color.Black.copy(alpha = 0.5f)
                        )
                        .padding(horizontal = 8.dp, vertical = 4.dp),
                    textAlign = TextAlign.Center // 텍스트 정렬
                )

                // 항목이 저장되어 있으면 하트 아이콘을 표시하는 이미지 뷰
                if (item.isSaved) {
                    Image(
                        painter = painterResource(id = R.drawable.ic_heart_24),
                        contentDescription = null,
                        modifier = Modifier
                            .size(24.dp)
                            .constrainAs(heartImageView) {
                                top.linkTo(
                                    parent.top,
                                    margin = 4.dp
                                ) // 상단 가장자리를 부모의 상단 가장자리에 링크하고 여백 추가
                                end.linkTo(
                                    parent.end,
                                    margin = 4.dp
                                ) // 끝 가장자리를 부모의 끝 가장자리에 링크하고 여백 추가
                            }
                    )
                }
            }
        }

        if (item.itemType == SearchListType.VIDEO) {
            Text(
                text = item.siteName ?: "",
                fontSize = 12.sp,
                color = Color.Black,
                modifier = Modifier.fillMaxWidth(),
                textAlign = TextAlign.Start,
                maxLines = 2, // 최대 라인 수
                overflow = TextOverflow.Ellipsis // 텍스트가 화면에 표시되는 영역을 넘어가는 경우 말 줄임표를 표시
            )
        }
    }
}

기존 코드

Compose로 마이그레이션 하기 전 레이아웃을 설계하고 정의하기 위한 XML 레이아웃과 Fragment

fragment_search.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/search_fragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ui.main.search.SearchListFragment">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/tool_bar"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent">

            <SearchView
                android:id="@+id/search_view"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:background="@drawable/bg_bottom_cancel"
                android:iconifiedByDefault="false"
                android:queryBackground="@null"
                android:queryHint="@string/search_view_hint"
                android:searchIcon="@drawable/ic_tab_search" />

        </androidx.appcompat.widget.Toolbar>

        <View
            android:id="@+id/view"
            android:layout_width="match_parent"
            android:layout_height="4dp"
            android:alpha="0.3"
            android:background="@drawable/view_horizontal_line"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/tool_bar" />

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/recycler_search"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_marginTop="8dp"
            app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/view"
            app:spanCount="2"
            tools:listitem="@layout/image_search_item" />

    </androidx.constraintlayout.widget.ConstraintLayout>

</FrameLayout>

image_search_item.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="12dp"
    app:cardCornerRadius="16dp">

    <ImageView
        android:id="@+id/iv_image"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:adjustViewBounds="true"
        android:background="@drawable/bg_round_image"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <ImageView
        android:id="@+id/iv_heart"
        android:layout_width="24dp"
        android:layout_height="24dp"
        android:layout_margin="8dp"
        android:src="@drawable/ic_heart_24"
        android:visibility="gone"
        app:layout_constraintEnd_toEndOf="@id/iv_image"
        app:layout_constraintTop_toTopOf="@id/iv_image" />

    <TextView
        android:id="@+id/tv_item_type"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="8dp"
        android:background="@drawable/bg_item_type"
        android:padding="6dp"
        android:textColor="@color/white"
        android:textSize="12sp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tv_image_site_name"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="4dp"
        android:ellipsize="end"
        android:gravity="start"
        android:maxLines="2"
        android:textSize="14sp"
        android:textStyle="bold"
        app:layout_constraintEnd_toEndOf="@id/iv_image"
        app:layout_constraintStart_toStartOf="@id/iv_image"
        app:layout_constraintTop_toBottomOf="@id/iv_image" />

    <TextView
        android:id="@+id/tv_image_datetime"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="4dp"
        android:textColor="#c8c8c8"
        android:textSize="12sp"
        app:layout_constraintEnd_toEndOf="@id/tv_image_site_name"
        app:layout_constraintStart_toStartOf="@id/tv_image_site_name"
        app:layout_constraintTop_toBottomOf="@id/tv_image_site_name" />

</androidx.constraintlayout.widget.ConstraintLayout>

SearchListFragment

class SearchListFragment : Fragment() {
    companion object {
        fun newInstance() = SearchListFragment()
    }

    private var _binding: FragmentSearchBinding? = null

    private val binding get() = _binding!!

    private val viewModel: SearchViewModel by viewModels {
        SearchViewModelProviderFactory(
            ImageSearchRepositoryImpl(requireActivity())
        )
    }

    private val searchListAdapter by lazy {
        SearchListAdapter(
            itemClickListener = {
                viewModel.updateStorageItem(it)
            }
        )
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentSearchBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        initView()
        initViewModel()
    }

    override fun onResume() {
        super.onResume()
        viewModel.reloadStorageItems()
    }

    private fun initView() {
        initSearchView()

        initRecyclerView()
    }

    private fun initRecyclerView() = with(binding) {
        recyclerSearch.adapter = searchListAdapter

        recyclerSearch.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
                super.onScrollStateChanged(recyclerView, newState)
                if (binding.recyclerSearch.canScrollVertically(1).not()
                    && newState == RecyclerView.SCROLL_STATE_IDLE
                ) {
                    viewModel.plusPageCount()
                }
            }
        })
    }

    private fun initViewModel() = with(viewModel) {
        searchResult.observe(viewLifecycleOwner) {
            searchListAdapter.submitList(it.list)

            if (it.showSnackMessage) {
                it.snackMessage?.let { resId ->
                    showSnackBar(resId)
                }
            }
        }

        searchWord.observe(viewLifecycleOwner) {
            binding.searchView.setQuery(it, false)
        }

        pageCounts.observe(viewLifecycleOwner) {
            val query = binding.searchView.query.toString()
            viewModel.searchCombinedResults(query)
        }
    }

    override fun onDestroyView() {
        _binding = null
        super.onDestroyView()
    }

    override fun onStop() {
        val query = binding.searchView.query.toString()
        viewModel.saveStorageSearchWord(query)
        super.onStop()
    }

    private fun initSearchView() = with(binding) {
        searchView.isSubmitButtonEnabled = true
        searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener,
            androidx.appcompat.widget.SearchView.OnQueryTextListener {
            override fun onQueryTextSubmit(query: String?): Boolean {
                if (query != null) {
                    viewModel.resetPageCount()
                }

                return false
            }

            override fun onQueryTextChange(newText: String?): Boolean = false
        })
    }

    private fun showSnackBar(resId: Int) {
        Snackbar.make(
            binding.searchFragment,
            getString(resId),
            Snackbar.LENGTH_SHORT
        ).show()
    }


    fun smoothScrollToTop() =
        binding.recyclerSearch.smoothScrollToPosition(0)

}
profile
개발 공부 기록 🌱

0개의 댓글