콘텐츠로 이동

4. 목록 화면 만들기

검색 화면의 최종 목표는 "검색어를 입력하면 장소들이 목록으로 좌르륵 나오는 것"입니다. 이번 장에서는 그 목록을 만듭니다. 다만 진짜 검색은 6장에서 연결하고, 지금은 우리가 직접 만든 연습용 데이터 세 곳으로 목록을 완성합니다. 화면부터 만들고 데이터는 나중에 갈아 끼우는 것 — 실제 개발자들이 즐겨 쓰는 순서입니다.

RecyclerView — 칸을 재활용하는 순환 열차

지하철 2호선은 순환선입니다. 열차 몇 대가 같은 선로를 빙글빙글 돌면서 수십만 명을 실어 나릅니다. 승객 수만큼 열차를 만들지 않습니다.

안드로이드의 목록 화면 RecyclerView 가 정확히 이 방식이다. 장소가 1,000개라도 화면에 보이는 줄은 열 개 남짓이므로, 줄(뷰) 열몇 개만 만들어 두고 화면 밖으로 사라진 줄을 재활용(recycle) 해서 새 데이터를 실어 다시 내보낸다. 그래서 아무리 긴 목록도 빠르고 가볍다.

RecyclerView 는 세 가지 부품이 협력해 움직인다.

  • 데이터 — 보여 줄 내용의 목록. 우리는 Place 객체의 List.
  • 아이템 레이아웃 — 목록 한 줄의 생김새를 정한 XML.
  • 어댑터(Adapter) — 데이터를 아이템 레이아웃에 실어 나르는 통역사. "몇 줄인지", "각 줄에 무엇을 채울지"를 어댑터가 답한다.

[CH-1 아래 코드를 그대로 작성하고 실행하시오.]

RecyclerView 는 별도 라이브러리이므로 의존성부터 추가합니다. app/build.gradledependencies 블록 안, implementation libs.activity 아래에 두 줄을 추가합니다.

    // 목록 화면 (4장)
    implementation 'androidx.recyclerview:recyclerview:1.3.2'

실행 결과

Gradle files have changed since last project sync. A project sync may be
necessary for the IDE to work properly.                    [Sync Now]

[설명]

  1. build.gradle 을 고치면 안드로이드 스튜디오 위쪽에 위와 같은 노란 안내줄이 나타난다. Sync Now 를 눌러야 새 라이브러리가 실제로 내려받아진다.
  2. 앞으로 5·6·7장에서도 라이브러리를 추가할 때마다 같은 과정(의존성 추가 → Sync Now)을 반복한다.

장소 데이터의 모양 정하기 — Place 클래스

목록에 실어 나를 "장소 하나"는 이름·주소·평점·좌표 같은 정보 묶음입니다. 이런 묶음은 클래스로 정의합니다.

[CH-2 아래 코드를 그대로 작성하고 실행하시오.]

java/com/example/myapplication 폴더에서 마우스 오른쪽 버튼 → New → Java Class 로 Place 클래스를 만들고 아래처럼 작성합니다.

package com.example.myapplication;

/** 앱 안에서 장소 하나를 표현하는 모델. 검색 결과를 이 모양으로 바꿔 목록·상세에 넘긴다. */
public class Place {

    public String id;        // 장소 고유 번호 (6장에서 Places API 가 채워 준다)
    public String name;
    public String address;
    public String rating;    // 평점이 없는 장소도 있어서 문자열로 둔다
    public double lat;
    public double lng;
    public String imageUrl;  // 장소 사진 주소 (없으면 null)

    public Place(String id, String name, String address, String rating,
                 double lat, double lng, String imageUrl) {
        this.id = id;
        this.name = name;
        this.address = address;
        this.rating = rating;
        this.lat = lat;
        this.lng = lng;
        this.imageUrl = imageUrl;
    }
}

[설명]

  1. 이런 클래스를 모델(model) 이라 부른다 — 앱 안에서 데이터가 어떤 모양인지 정한 설계도다.
  2. lat(위도)·lng(경도)는 지도 위의 좌표다. 5장에서 지도가 생기면 이 좌표로 마커를 꽂는다.
  3. 6장에서 Google 의 검색 결과를 받아 오면, 그 복잡한 응답을 전부 이 Place 모양으로 바꿔서 쓴다. 바깥 세상의 데이터가 어떻게 생겼든 앱 내부는 한 가지 모양만 다루게 하는 정리 습관이다.

목록 한 줄의 생김새 — 아이템 레이아웃

[CH-3 아래 코드를 그대로 작성하고 실행하시오.]

res/layout 폴더에서 마우스 오른쪽 버튼 → New → Layout Resource File 로 item_place.xml 을 만들고 아래처럼 작성합니다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="?attr/selectableItemBackground"
    android:orientation="vertical"
    android:padding="16dp">

    <TextView
        android:id="@+id/item_name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="16sp"
        android:textStyle="bold" />

    <TextView
        android:id="@+id/item_address"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="2dp"
        android:textSize="13sp" />

    <TextView
        android:id="@+id/item_rating"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="2dp"
        android:textSize="13sp" />

</LinearLayout>

[설명]

  1. 이름(굵게) → 주소 → 평점을 세로로 쌓은, 목록 한 줄의 설계도다. 이 레이아웃이 줄 수만큼 복제되어 목록이 된다.
  2. android:background="?attr/selectableItemBackground" 는 "누르는 동안 살짝 어두워지는 효과"를 입히는 테마 속성이다. 3장에서 배운 ?attr/ 문법이 여기서도 쓰였다 — 목록 줄을 탭할 수 있다는 느낌을 주는 관용구다.

통역사 고용하기 — 어댑터

[CH-4 아래 코드를 그대로 작성하고 실행하시오.]

SearchResultAdapter 클래스를 새로 만들고 아래처럼 작성합니다. 이 장에서 가장 긴 코드이지만, 구조를 알고 보면 세 가지 질문에 답하는 문서일 뿐입니다.

package com.example.myapplication;

import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;

import java.util.List;

public class SearchResultAdapter extends RecyclerView.Adapter<SearchResultAdapter.PlaceViewHolder> {

    /** 목록의 한 줄을 탭했을 때 무엇을 할지는 화면(액티비티)이 정한다. */
    public interface OnPlaceClickListener {
        void onPlaceClick(Place place);
    }

    private final List<Place> places;
    private final OnPlaceClickListener listener;

    public SearchResultAdapter(List<Place> places, OnPlaceClickListener listener) {
        this.places = places;
        this.listener = listener;
    }

    @NonNull
    @Override
    public PlaceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View itemView = LayoutInflater.from(parent.getContext())
                .inflate(R.layout.item_place, parent, false);
        return new PlaceViewHolder(itemView);
    }

    @Override
    public void onBindViewHolder(@NonNull PlaceViewHolder holder, int position) {
        Place place = places.get(position);
        holder.nameText.setText(place.name);
        holder.addressText.setText(place.address);
        if (place.rating == null) {
            holder.ratingText.setText(R.string.detail_no_rating);
        } else {
            holder.ratingText.setText("⭐ " + place.rating);
        }
        holder.itemView.setOnClickListener(v -> listener.onPlaceClick(place));
    }

    @Override
    public int getItemCount() {
        return places.size();
    }

    static class PlaceViewHolder extends RecyclerView.ViewHolder {
        TextView nameText;
        TextView addressText;
        TextView ratingText;

        PlaceViewHolder(View itemView) {
            super(itemView);
            nameText = itemView.findViewById(R.id.item_name);
            addressText = itemView.findViewById(R.id.item_address);
            ratingText = itemView.findViewById(R.id.item_rating);
        }
    }
}

아직 R.string.detail_no_rating 이 없어서 빨간 줄이 뜹니다. 다음 CH 에서 문구를 추가하면 사라집니다.

[설명]

  1. 어댑터는 RecyclerView 의 세 가지 질문에 답한다. getItemCount() — 모두 몇 줄인가? onCreateViewHolder() — 새 줄이 필요하면 어떻게 만드나? onBindViewHolder() — position 번째 줄에 무엇을 채우나?
  2. ViewHolder 는 한 줄 안의 TextView 들을 붙잡아 두는 손잡이다. 줄을 재활용할 때마다 findViewById 를 다시 하면 느려지므로, 처음 한 번만 찾아서 들고 다닌다.
  3. onBindViewHolder 가 바로 재활용 현장이다 — 화면 밖으로 나간 줄(holder)이 돌아오면 새 position 의 데이터로 내용만 갈아 끼운다.
  4. 맨 위의 OnPlaceClickListener 인터페이스에 주목한다. 어댑터는 "줄이 눌렸다"는 사실만 알리고, 눌렸을 때 무엇을 할지는 화면(액티비티)이 정하게 넘긴다. 그래서 같은 어댑터를 어느 화면에서든 다르게 활용할 수 있다.

검색 화면 조립하기

부품이 다 모였습니다. 검색 화면에 검색창과 RecyclerView 를 배치하고, 어댑터에 연습용 데이터를 실어 연결합니다.

[CH-5 아래 코드를 그대로 작성하고 실행하시오.]

새 화면에 쓸 문구를 strings.xml 에 추가합니다. 전체를 아래 내용으로 맞춥니다.

<resources>
    <string name="app_name">버킷맵</string>
    <string name="btn_find_place">장소 찾기</string>
    <string name="btn_my_places"> 장소</string>
    <string name="map_placeholder">지도가 들어갈 자리</string>
    <string name="btn_wish">가보고 싶어요</string>
    <string name="btn_visited">다녀왔어요</string>
    <string name="btn_remove">빼기</string>
    <string name="detail_photo_desc">장소 사진</string>
    <string name="detail_no_rating">평점 없음</string>
    <string name="toast_not_ready">아직 준비 중입니다</string>
    <string name="search_hint">어디로 가고 싶나요?</string>
    <string name="btn_search">검색</string>
    <string name="chip_wish">가볼 </string>
    <string name="chip_visited">다녀온 </string>
    <string name="my_places_empty">아직 저장한 장소가 없습니다</string>
</resources>

[CH-6 아래 코드를 그대로 작성하고 실행하시오.]

activity_search.xml 을 임시 화면에서 진짜 검색 화면으로 바꿉니다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center_vertical"
        android:orientation="horizontal"
        android:padding="12dp">

        <EditText
            android:id="@+id/edit_keyword"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:hint="@string/search_hint"
            android:imeOptions="actionSearch"
            android:inputType="text" />

        <Button
            android:id="@+id/btn_search"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:text="@string/btn_search" />

    </LinearLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list_results"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />

</LinearLayout>

[설명]

  1. EditText 는 글자를 입력받는 뷰다. hint 는 비어 있을 때 흐리게 보이는 안내 문구, imeOptions="actionSearch" 는 키보드 엔터 키를 돋보기 모양으로 바꿔 준다.
  2. 위 줄(검색창+버튼)은 필요한 만큼만 차지하고, RecyclerView 가 layout_weight="1"남은 세로 공간 전부를 가져간다. 3장에서 버튼 두 개가 가로 공간을 나눠 갖던 것과 같은 원리다.
  3. RecyclerView 는 기본 위젯이 아니라서 androidx.recyclerview.widget.RecyclerView 처럼 전체 이름을 적는다.

[CH-7 아래 코드를 그대로 작성하고 실행하시오.]

SearchActivity.java 를 아래처럼 바꿉니다. 임시 버튼 코드는 사라지고, 연습용 장소 세 곳이 목록에 실립니다.

package com.example.myapplication;

import android.content.Intent;
import android.os.Bundle;
import android.widget.Button;
import android.widget.Toast;

import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import java.util.ArrayList;
import java.util.List;

public class SearchActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_search);

        RecyclerView resultList = findViewById(R.id.list_results);
        resultList.setLayoutManager(new LinearLayoutManager(this));

        // 진짜 검색은 6장에서 연결한다 — 지금은 연습용 더미 데이터
        List<Place> dummyPlaces = new ArrayList<>();
        dummyPlaces.add(new Place("dummy-1", "경복궁", "서울 종로구 사직로 161",
                "4.6", 37.5796, 126.9770, null));
        dummyPlaces.add(new Place("dummy-2", "N서울타워", "서울 용산구 남산공원길 105",
                "4.5", 37.5512, 126.9882, null));
        dummyPlaces.add(new Place("dummy-3", "국립중앙박물관", "서울 용산구 서빙고로 137",
                "4.7", 37.5240, 126.9804, null));

        SearchResultAdapter adapter = new SearchResultAdapter(dummyPlaces, place -> {
            Intent intent = new Intent(this, PlaceDetailActivity.class);
            intent.putExtra(PlaceDetailActivity.EXTRA_NAME, place.name);
            intent.putExtra(PlaceDetailActivity.EXTRA_ADDRESS, place.address);
            intent.putExtra(PlaceDetailActivity.EXTRA_RATING, place.rating);
            startActivity(intent);
        });
        resultList.setAdapter(adapter);

        Button searchButton = findViewById(R.id.btn_search);
        searchButton.setOnClickListener(v ->
                Toast.makeText(this, R.string.toast_not_ready, Toast.LENGTH_SHORT).show());
    }
}

아직 PlaceDetailActivity.EXTRA_ADDRESS 가 없어서 빨간 줄이 뜹니다. 다음 CH 에서 추가합니다.

[설명]

  1. RecyclerView 를 쓰는 순서는 항상 같다 — ① LayoutManager 지정(줄을 세로로 쌓는 LinearLayoutManager), ② 어댑터를 만들어, ③ setAdapter 로 연결.
  2. 어댑터를 만들 때 두 번째 인자로 "줄이 눌리면 할 일"을 람다로 넘겼다. 상세 화면으로 가는 Intent 에 이름·주소·평점을 실어 보낸다 — 2장에서 배운 putExtra 그대로다.
  3. "떡볶이 맛집"을 보내던 임시 버튼은 지웠다. 이제 목록의 모든 줄이 상세 화면으로 가는 문이 된다.
  4. 검색 버튼은 아직 할 일이 없어 안내 문구만 띄운다. 6장에서 진짜 검색으로 바뀐다.

[CH-8 아래 코드를 그대로 작성하고 실행하시오.]

상세 화면이 주소와 평점도 받을 수 있게 PlaceDetailActivity.java 를 아래처럼 바꿉니다.

package com.example.myapplication;

import android.os.Bundle;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;

import androidx.appcompat.app.AppCompatActivity;

public class PlaceDetailActivity extends AppCompatActivity {

    public static final String EXTRA_NAME = "extra_name";
    public static final String EXTRA_ADDRESS = "extra_address";
    public static final String EXTRA_RATING = "extra_rating";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_place_detail);

        TextView nameText = findViewById(R.id.text_place_name);
        TextView addressText = findViewById(R.id.text_place_address);
        TextView ratingText = findViewById(R.id.text_place_rating);

        nameText.setText(getIntent().getStringExtra(EXTRA_NAME));
        addressText.setText(getIntent().getStringExtra(EXTRA_ADDRESS));

        String rating = getIntent().getStringExtra(EXTRA_RATING);
        if (rating == null) {
            ratingText.setText(R.string.detail_no_rating);
        } else {
            ratingText.setText("⭐ " + rating);
        }

        Button wishButton = findViewById(R.id.btn_wish);
        Button visitedButton = findViewById(R.id.btn_visited);
        Button removeButton = findViewById(R.id.btn_remove);

        wishButton.setOnClickListener(v ->
                Toast.makeText(this, R.string.toast_not_ready, Toast.LENGTH_SHORT).show());
        visitedButton.setOnClickListener(v ->
                Toast.makeText(this, R.string.toast_not_ready, Toast.LENGTH_SHORT).show());
        removeButton.setOnClickListener(v ->
                Toast.makeText(this, R.string.toast_not_ready, Toast.LENGTH_SHORT).show());
    }
}

실행 결과

앱을 실행하고 장소 찾기 를 누르면 연습용 장소 세 곳이 목록으로 나타납니다.

검색 화면에 연습용 장소 세 곳이 목록으로 표시된 모습: 위에 '어디로 가고 싶나요?' 검색창과 코랄색 검색 버튼, 아래로 경복궁·N서울타워·국립중앙박물관이 각각 이름·주소·별점과 함께 줄지어 있다

경복궁 줄을 탭하면 상세 화면이 열리고, 이번에는 주소와 평점까지 제대로 채워집니다.

목록에서 경복궁을 탭해 열린 상세 화면: 회색 사진 자리 아래 경복궁 이름, 서울 종로구 사직로 161 주소, 별점 4.6, 저장 상태 임시 문구와 버튼 세 개가 표시되어 있다

[설명]

  1. 상수 EXTRA_ADDRESS · EXTRA_RATING 이 추가되고, 받은 값을 3장에서 만들어 둔 TextView 들에 채운다. 임시 문구 "주소가 여기에 표시됩니다"가 진짜 주소로 덮이는 순간이다.
  2. 평점은 null 인지 먼저 확인한다. 세상의 모든 장소에 평점이 있는 것은 아니다 — 데이터가 없을 가능성을 항상 의심하는 습관이 오류 없는 앱을 만든다.
  3. 뒤로 가기를 누르면 목록 화면이 그대로 남아 있다. 2장에서 배운 back 스택 덕분이다.

내 장소 화면의 뼈대

내 장소 화면도 목록 화면입니다. 다만 "가볼 곳"과 "다녀온 곳"을 나눠 보는 필터가 위에 붙습니다. 필터로는 알약 모양 버튼인 Chip 을 씁니다.

[CH-9 아래 코드를 그대로 작성하고 실행하시오.]

activity_my_places.xml 을 아래처럼 바꿉니다. (MyPlacesActivity.java 는 2장 그대로 두면 됩니다.)

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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="match_parent"
    android:orientation="vertical">

    <com.google.android.material.chip.ChipGroup
        android:id="@+id/chip_group_filter"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="12dp"
        android:layout_marginTop="8dp"
        app:selectionRequired="true"
        app:singleSelection="true">

        <com.google.android.material.chip.Chip
            android:id="@+id/chip_wish"
            style="@style/Widget.Material3.Chip.Filter"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:checked="true"
            android:text="@string/chip_wish" />

        <com.google.android.material.chip.Chip
            android:id="@+id/chip_visited"
            style="@style/Widget.Material3.Chip.Filter"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/chip_visited" />

    </com.google.android.material.chip.ChipGroup>

    <TextView
        android:id="@+id/text_empty"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:layout_marginTop="48dp"
        android:text="@string/my_places_empty"
        android:textSize="15sp" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list_my_places"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />

</LinearLayout>

실행 결과

메인 화면에서 내 장소 를 누르면 필터 칩과 빈 목록 안내가 보입니다.

내 장소 화면 뼈대: 왼쪽 위에 '가볼 곳' 칩이 선택된 상태로 '다녀온 곳' 칩과 나란히 있고, 아래에 '아직 저장한 장소가 없습니다' 안내 문구가 표시되어 있다

[설명]

  1. Chip 은 Material 라이브러리가 제공하는 알약 모양 선택 버튼이다. 누르면 체크 상태가 토글되는, 작은 스위치라고 생각하면 된다.
  2. ChipGroupsingleSelection="true" 는 한 번에 하나만 선택되게, selectionRequired="true" 는 최소 하나는 항상 선택되어 있게 강제한다. 라디오 버튼과 같은 동작을 칩으로 만든 것이다.
  3. app: 으로 시작하는 속성은 안드로이드 기본이 아니라 라이브러리가 추가한 속성이라, 맨 위에 xmlns:app 선언이 필요하다.
  4. 빈 상태 안내(text_empty)를 미리 두었다. 7장에서 저장된 장소가 생기면 이 문구를 숨기고 목록을 채운다.

정리

이번 장에서 배운 것을 정리합니다.

  1. RecyclerView 는 줄 몇 개를 재활용해 긴 목록을 그린다 — 순환 열차 방식이다.
  2. 어댑터는 데이터와 화면 사이의 통역사로, 줄 수·줄 생성·줄 채우기 세 질문에 답한다.
  3. 클릭 처리는 어댑터가 인터페이스로 화면에 위임한다 — 어댑터는 재사용 가능한 부품으로 남는다.
  4. 진짜 데이터가 오기 전에 더미 데이터로 화면을 먼저 완성하는 것이 효율적인 개발 순서다.

[self Test 1] - 연습용 장소를 다섯 곳으로 늘려 보시오. 우리 동네에서 가 보고 싶은 곳 두 곳을 골라 이름·주소를 넣고, 평점 하나는 일부러 null 로 넣어 "평점 없음"이 표시되는지 확인하시오. (좌표는 아직 몰라도 된다 — 0, 0 을 넣어 두고 5장에서 진짜 좌표 찾는 법을 배운다.)

[self Test 2] - 아이템 레이아웃의 이름 글자 크기를 18sp 로 키우고 실행해, 목록 전체 줄 높이가 어떻게 달라지는지 관찰하시오.