콘텐츠로 이동

3. 레이아웃 구성하기

2장까지의 버킷맵은 화면마다 글자 한 줄과 버튼 몇 개가 전부인 "뼈대만 있는 앱"이었습니다. 이번 장에서는 앱의 얼굴을 만듭니다. 버킷맵만의 색을 정하고, 런처 아이콘을 직접 그리고, 메인 화면과 상세 화면의 모양을 완성합니다. 이 장이 끝나면 앱 서랍에서도, 화면에서도 버킷맵은 완전히 "다른 앱"처럼 보이게 됩니다.

레이아웃은 상자를 배치하는 규칙

옷장 정리를 떠올려 봅시다. 옷을 위에서 아래로 차곡차곡 쌓을 수도 있고, 얇은 옷 위에 두꺼운 옷을 겹쳐 둘 수도 있습니다. 안드로이드 화면도 똑같습니다. 글자, 버튼, 이미지 같은 뷰(View) 들을 어떤 규칙으로 배치할지 정하는 것이 레이아웃(Layout) 이다.

이번 장에서는 두 가지 레이아웃만 사용한다.

  • LinearLayout — 책꽂이. 뷰를 한 방향(세로 또는 가로)으로 차곡차곡 배치한다. android:orientation 속성이 방향을 정한다.
  • FrameLayout — 투명 필름 겹치기. 뷰를 같은 자리에 층층이 겹쳐 놓는다. 나중에 적은 뷰가 위층에 올라온다. "지도 위에 버튼을 띄우는" 화면에 딱 맞는 구조다.

크기 단위도 두 가지만 기억하면 된다.

  • dp — 뷰의 크기·간격에 쓰는 단위. 화면 해상도가 달라도 실제 보이는 크기가 비슷하게 유지된다.
  • sp — 글자 크기 전용 단위. dp 와 같지만, 사용자가 시스템 설정에서 글자를 키우면 함께 커진다.

픽셀(px)을 직접 쓰지 않는 이유는 간단하다. 스마트폰마다 화면에 박힌 픽셀 수가 달라서, px 로 지정하면 어떤 폰에서는 크고 어떤 폰에서는 작아 보이기 때문이다.

버킷맵의 색 정하기

버킷맵의 테마는 "여행 노을"입니다. 노을빛 코랄을 주인공 색으로, 깊은 바다색 틸을 보조 색으로 사용합니다. 색은 코드 여기저기에 흩어 놓지 않고 res/values/colors.xml 한 곳에 이름을 붙여 모아 둡니다.

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

app/src/main/res/values/colors.xml 파일을 아래 내용으로 바꿉니다. (파일이 없다면 values 폴더에서 마우스 오른쪽 버튼 → New → Values Resource File 로 만듭니다.)

<resources>
    <color name="white">#FFFFFFFF</color>
    <color name="black">#FF000000</color>

    <!-- 버킷맵 브랜드 색 — "여행 노을" 팔레트 -->
    <color name="brand_coral">#E8553F</color>
    <color name="brand_teal">#00696D</color>

    <!-- 다크 모드용 밝은 변형 (어두운 배경 위에서는 밝은 색이 잘 보인다) -->
    <color name="brand_coral_light">#FFB4A5</color>
    <color name="brand_coral_deep">#5F1608</color>
    <color name="brand_teal_light">#70D5DA</color>
    <color name="brand_teal_deep">#00363A</color>

    <!-- 지도 마커 색 — 찜(주황)과 방문 완료(초록) -->
    <color name="marker_wish">#FF8A00</color>
    <color name="marker_visited">#2E7D32</color>
</resources>

[설명]

  1. #E8553F 처럼 # 뒤에 오는 여섯 글자는 빨강·초록·파랑의 양을 나타내는 16진수 색상 코드다. 여덟 글자면 맨 앞 두 글자가 투명도다.
  2. name 으로 붙인 이름은 다른 파일에서 @color/brand_coral 처럼 불러 쓴다. 나중에 색을 바꾸고 싶으면 이 파일 한 곳만 고치면 된다.
  3. marker_wish(주황)와 marker_visited(초록)는 7장에서 지도 마커에 쓸 색이다. 미리 정해 둔다.

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

정해 둔 색을 앱 전체에 입히는 곳이 테마입니다. app/src/main/res/values/themes.xml 을 아래처럼 바꿉니다.

<resources>
    <style name="Base.Theme.BucketMap" parent="Theme.Material3.DayNight.NoActionBar">
        <item name="colorPrimary">@color/brand_coral</item>
        <item name="colorOnPrimary">@color/white</item>
        <item name="colorSecondary">@color/brand_teal</item>
        <item name="colorOnSecondary">@color/white</item>
        <item name="android:statusBarColor">@color/brand_coral</item>
    </style>

    <style name="Theme.BucketMap" parent="Base.Theme.BucketMap" />
</resources>

이어서 다크 모드용 테마인 app/src/main/res/values-night/themes.xml 도 바꿉니다.

<resources>
    <style name="Base.Theme.BucketMap" parent="Theme.Material3.DayNight.NoActionBar">
        <item name="colorPrimary">@color/brand_coral_light</item>
        <item name="colorOnPrimary">@color/brand_coral_deep</item>
        <item name="colorSecondary">@color/brand_teal_light</item>
        <item name="colorOnSecondary">@color/brand_teal_deep</item>
    </style>
</resources>

[설명]

  1. colorPrimary 는 앱의 주인공 색이다. 버튼처럼 눈에 띄어야 하는 부품이 자동으로 이 색을 입는다. 우리가 버튼마다 색을 칠하지 않아도 되는 이유다.
  2. colorOnPrimary 는 "주인공 색 위에 올라가는" 글자·아이콘의 색이다. 코랄 버튼 위 흰 글자가 여기서 나온다.
  3. values-night 폴더의 파일은 기기가 다크 모드일 때만 대신 사용된다. 어두운 배경에서는 진한 코랄이 잘 안 보이므로 밝은 변형(brand_coral_light)을 쓴다.
  4. 실행해도 아직 화면 변화가 크지 않다. 다음 절에서 화면 뼈대를 만들면서 한꺼번에 확인한다.

메인 화면의 뼈대 잡기

메인 화면의 최종 모습은 "화면 전체를 채운 지도 + 그 위에 떠 있는 버튼 두 개"입니다. 지도는 5장에서 붙이므로, 지금은 지도가 들어갈 자리를 빈 상자로 잡아 두고 그 위에 버튼을 겹칩니다. 겹치는 구조이니 FrameLayout 차례입니다.

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

먼저 화면에 쓸 문구를 app/src/main/res/values/strings.xml 에 정리합니다. 2장의 환영 문구는 임무를 다했으니 지웁니다.

<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="toast_not_ready">아직 준비 중입니다</string>
</resources>

[설명]

  1. main_welcome 이 사라지면서 그 문구를 쓰던 곳이 없어야 한다. 다음 CH 에서 레이아웃을 통째로 바꾸므로 함께 정리된다.
  2. btn_wish(가보고 싶어요)처럼 아직 안 쓰는 문구도 미리 넣어 두었다. 이 장 뒷부분의 상세 화면에서 바로 쓴다.

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

app/src/main/res/layout/activity_main.xml 을 아래처럼 바꿉니다.

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

    <!-- 지도 자리 — 5장에서 진짜 네이버 지도로 바뀐다 -->
    <FrameLayout
        android:id="@+id/map_container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#D8E5DB">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:text="@string/map_placeholder"
            android:textSize="18sp" />
    </FrameLayout>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        android:layout_margin="16dp"
        android:orientation="horizontal">

        <Button
            android:id="@+id/btn_find_place"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:layout_marginEnd="8dp"
            android:text="@string/btn_find_place" />

        <Button
            android:id="@+id/btn_my_places"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="@string/btn_my_places" />

    </LinearLayout>

</FrameLayout>

[설명]

  1. 바깥 FrameLayout 안에 두 층이 겹쳐 있다. 아래층은 화면 전체를 채우는 지도 자리(map_container), 위층은 화면 바닥에 붙는 버튼 줄이다.
  2. android:layout_gravity="bottom" 이 버튼 줄을 화면 아래쪽에 붙인다. FrameLayout 안에서 각 층의 위치는 이 속성으로 정한다.
  3. 버튼 두 개는 layout_width="0dp" + layout_weight="1" 로 되어 있다. LinearLayout 이 남는 가로 공간을 1:1 로 나눠 주라는 뜻으로, 두 버튼이 정확히 반씩 차지한다.
  4. 실행하면 버튼과 상태바가 코랄색이다 — 테마의 colorPrimary 가 자동으로 입혀진 것이다. MainActivity 의 자바 코드는 한 줄도 고치지 않았는데 화면이 완전히 달라졌다.

그런데 지도 자리의 배경색 #D8E5DB 를 레이아웃에 직접 적었습니다. 이렇게 하면 어떤 문제가 생기는지, 잠시 뒤 다크 모드 실험에서 직접 확인합니다.

다크 모드를 켜 보면 — 하드코딩 색의 배신

[CH-5 아래 과정을 그대로 진행하시오.]

  1. 에뮬레이터에서 설정 > 디스플레이 > 다크 테마 를 켭니다.
  2. 버킷맵을 다시 엽니다.

실행 결과

다크 모드를 켰지만 지도 자리만 밝은 연녹색으로 남아 있는 메인 화면. 버튼은 밝은 코랄로 바뀌었는데 화면 대부분을 차지하는 지도 자리가 어두워지지 않아 어색하다

버튼과 상태바는 다크 모드용 밝은 코랄로 알아서 바뀌었는데, 화면 대부분을 차지하는 지도 자리는 밝은 연녹색 그대로입니다. 절반만 다크 모드가 된 셈입니다.

[설명]

  1. #D8E5DB 처럼 색상 코드를 레이아웃에 직접 적는 것을 하드코딩이라 한다. 하드코딩된 색은 테마가 바뀌어도 절대 변하지 않는다.
  2. 버튼이 알아서 바뀐 이유는 버튼이 색을 직접 들고 있지 않고 "테마의 colorPrimary 를 쓰겠다"고만 해 두었기 때문이다.
  3. 해결책도 같은 방식이다 — 지도 자리도 색을 직접 들지 말고 테마에게 물어보게 만들면 된다.

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

activity_main.xml 에서 지도 자리의 배경 한 줄만 고칩니다.

        android:background="?attr/colorSurfaceVariant">

실행 결과

다크 테마를 끈 라이트 모드의 메인 화면 뼈대: 화면 전체가 은은한 회색 지도 자리로 채워져 있고 가운데에 '지도가 들어갈 자리' 문구, 아래쪽에 코랄색 '장소 찾기'와 '내 장소' 버튼 두 개가 나란히 떠 있다

다크 테마를 켠 메인 화면: 지도 자리가 어두운 회색으로 함께 어두워지고 버튼은 밝은 코랄로 표시된다

라이트 모드에서는 은은한 회색, 다크 모드에서는 어두운 회색 — 지도 자리가 테마를 따라다닙니다. 확인이 끝나면 설정 > 디스플레이 > 다크 테마 를 다시 꺼 둡니다.

[설명]

  1. ?attr/colorSurfaceVariant 는 "지금 적용된 테마의 colorSurfaceVariant 색을 가져와라"라는 뜻이다. @color/ 가 색 자체를 가리킨다면 ?attr/ 는 테마의 색 자리를 가리킨다.
  2. colorSurfaceVariant 는 Material 테마가 기본으로 갖고 있는 "바탕보다 살짝 진한 면" 색이다. 우리가 직접 정의하지 않아도 라이트/다크 값이 이미 준비되어 있다.
  3. 화면에 보이는 색을 정할 때의 원칙: 브랜드 색은 @color/ 로 이름 붙여 쓰고, 배경·면처럼 테마를 따라야 하는 색은 ?attr/ 로 쓴다.

상세 화면의 뼈대 잡기

상세 화면은 위에서 아래로 사진 → 이름 → 주소 → 평점 → 저장 상태 → 버튼 순서로 쌓이는 구조입니다. 한 방향으로 차곡차곡 — LinearLayout 차례입니다.

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

app/src/main/res/layout/activity_place_detail.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">

    <ImageView
        android:id="@+id/image_place"
        android:layout_width="match_parent"
        android:layout_height="220dp"
        android:background="#EDEDED"
        android:contentDescription="@string/detail_photo_desc"
        android:scaleType="centerCrop" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:padding="20dp">

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

        <TextView
            android:id="@+id/text_place_address"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:text="주소가 여기에 표시됩니다"
            android:textSize="14sp" />

        <TextView
            android:id="@+id/text_place_rating"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="4dp"
            android:text="평점 -"
            android:textSize="14sp" />

        <TextView
            android:id="@+id/text_status"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:text="저장 상태가 여기에 표시됩니다"
            android:textSize="14sp"
            android:textStyle="bold" />

        <Button
            android:id="@+id/btn_wish"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:text="@string/btn_wish" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:orientation="horizontal">

            <Button
                android:id="@+id/btn_visited"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:layout_marginEnd="8dp"
                android:text="@string/btn_visited" />

            <Button
                android:id="@+id/btn_remove"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:text="@string/btn_remove" />

        </LinearLayout>

    </LinearLayout>

</LinearLayout>

[설명]

  1. 세로 LinearLayout 안에 가로 LinearLayout 이 들어 있다. 레이아웃은 이렇게 상자 안에 상자를 넣어 얼마든지 조합할 수 있다.
  2. ImageView 의 scaleType="centerCrop" 은 사진 비율이 안 맞을 때 가운데를 기준으로 꽉 채워 자르는 방식이다. 6장에서 진짜 장소 사진이 여기 들어온다.
  3. "주소가 여기에 표시됩니다" 같은 문구는 화면 모양을 미리 확인하기 위한 임시 글자다. 코드가 진짜 데이터를 넣는 순간 덮어써진다.
  4. padding 은 상자 안쪽 여백, layout_margin 은 상자 바깥쪽 여백이다. 사진은 여백 없이 꽉 차게, 글자들은 20dp 안쪽 여백을 두고 배치했다.

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

새 레이아웃에는 버튼이 세 개 생겼습니다. 아직 저장 기능이 없으므로(7장에서 완성합니다), 지금은 눌리는지만 확인할 수 있게 안내 문구를 띄웁니다. 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";

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

        TextView nameText = findViewById(R.id.text_place_name);
        String name = getIntent().getStringExtra(EXTRA_NAME);
        nameText.setText(name);

        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());
    }
}

실행 결과

앱을 실행해 장소 찾기 → 상세 화면 열기 (테스트) 순서로 들어가면 새 상세 화면이 열립니다.

상세 화면 뼈대: 위쪽에 회색 사진 자리, 그 아래 2장에서 전달받은 '떡볶이 맛집' 이름과 임시 주소·평점 문구, 저장 상태 문구, 코랄색 '가보고 싶어요' 큰 버튼과 '다녀왔어요'·'빼기' 버튼 두 개가 쌓여 있다

2장에서 만든 Intent 전달이 그대로 살아 있어서, 이름 자리에는 여전히 "떡볶이 맛집"이 표시됩니다. 버튼을 누르면 "아직 준비 중입니다" 안내가 잠깐 떴다 사라집니다.

[설명]

  1. Toast 는 화면 아래에 잠깐 떴다가 사라지는 안내 문구다. Toast.makeText(문맥, 문구, 길이).show() 세 단계로 쓴다.
  2. 버튼 세 개가 지금은 모두 같은 안내만 띄우지만, 7장에서 각각 찜 저장·방문 도장·삭제 기능으로 바뀐다. 화면부터 완성하고 기능을 나중에 채우는 것은 실제 앱 개발에서도 흔한 순서다.

버킷맵의 얼굴 — 런처 아이콘 그리기

마지막으로 앱 서랍의 초록 로봇을 버킷맵의 얼굴로 바꿉니다. 아이콘은 그림 파일 대신 벡터 드로어블(XML 로 적는 그림)로 만듭니다. 벡터는 아무리 확대해도 깨지지 않고, 파일 크기도 작습니다.

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

아이콘의 배경(뒤판)입니다. app/src/main/res/drawable/ic_launcher_background.xml 을 아래처럼 바꿉니다.

<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="108dp"
    android:height="108dp"
    android:viewportWidth="108"
    android:viewportHeight="108">
    <path
        android:fillColor="#E8553F"
        android:pathData="M0,0 h108 v108 h-108 z" />
</vector>

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

아이콘의 전경(앞판)입니다. app/src/main/res/drawable/ic_launcher_foreground.xml 을 아래처럼 바꿉니다.

<?xml version="1.0" encoding="utf-8"?>
<!-- 버킷맵 아이콘 — 지도 핀 안에 "다녀왔다" 체크 표시 -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="108dp"
    android:height="108dp"
    android:viewportWidth="108"
    android:viewportHeight="108">
    <path
        android:fillColor="#FFFFFF"
        android:pathData="M54,28 C43,28 34,37 34,48 c0,14 20,32 20,32 s20,-18 20,-32 C74,37 65,28 54,28 z" />
    <path
        android:strokeColor="#E8553F"
        android:strokeWidth="5"
        android:strokeLineCap="round"
        android:strokeLineJoin="round"
        android:pathData="M45,48 L52,55 L64,41" />
</vector>

실행 결과

앱을 다시 설치한 뒤 홈 화면에서 위로 쓸어올려 앱 서랍을 엽니다.

앱 서랍에 새 버킷맵 아이콘이 보인다: 코랄색 동그란 배경 안에 흰 지도 핀이 있고 핀 안에 코랄색 체크 표시가 그려져 있다

코랄 바탕에 흰 지도 핀, 핀 안에는 "다녀왔다" 체크 — 버킷맵의 정체성이 아이콘 하나에 담겼습니다.

[설명]

  1. pathData 는 펜이 움직이는 길을 좌표로 적은 것이다(M = 이동, L = 직선, C/s = 곡선, z = 닫기). 지금 다 읽을 줄 몰라도 전혀 문제없다 — 디자이너가 만든 벡터 파일을 가져다 쓰는 경우가 대부분이다.
  2. 첫 번째 path 는 흰색으로 채운(fillColor) 핀 모양이고, 두 번째 path 는 코랄색 선으로 그린(strokeColor) 체크 표시다.
  3. 안드로이드 8.0 부터 런처 아이콘은 배경과 전경 두 층을 겹쳐 만든다(어댑티브 아이콘). 기기마다 동그라미·네모 등 다른 틀로 잘라 써도 두 층 구조 덕분에 자연스럽게 보인다. 두 층을 조합하라는 설정은 res/mipmap-anydpi-v26/ic_launcher.xml 에 이미 들어 있다.

정리

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

  1. LinearLayout 은 한 방향으로 차곡차곡, FrameLayout 은 같은 자리에 겹겹이 배치한다.
  2. 색은 colors.xml 에 이름 붙여 모으고, 테마(themes.xml)의 colorPrimary 가 앱 전체에 퍼진다.
  3. 색을 하드코딩하면 다크 모드가 무시된다 — 테마를 따라야 하는 색은 ?attr/ 로 쓴다.
  4. 런처 아이콘은 배경 + 전경 두 장의 벡터 드로어블로 만든다.

[self Test 1] - 버킷맵의 색을 나만의 팔레트로 바꿔 보시오. colors.xml 의 brand_coralbrand_teal 값을 좋아하는 색상 코드로 바꾸고, 다크 모드용 밝은 변형도 어울리게 고른 뒤, 라이트/다크 모드에서 각각 실행해 확인하시오. (색상 코드는 인터넷에서 "color picker" 를 검색하면 쉽게 고를 수 있다.)

[self Test 2] - 상세 화면의 평점 아래에 "영업 중" 이라고 표시되는 TextView 를 하나 추가해 보시오. id 는 text_open, 글자 크기는 14sp, 위쪽 여백은 4dp 로 하시오.