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>
[설명]
#E8553F처럼#뒤에 오는 여섯 글자는 빨강·초록·파랑의 양을 나타내는 16진수 색상 코드다. 여덟 글자면 맨 앞 두 글자가 투명도다.name으로 붙인 이름은 다른 파일에서@color/brand_coral처럼 불러 쓴다. 나중에 색을 바꾸고 싶으면 이 파일 한 곳만 고치면 된다.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>
[설명]
colorPrimary는 앱의 주인공 색이다. 버튼처럼 눈에 띄어야 하는 부품이 자동으로 이 색을 입는다. 우리가 버튼마다 색을 칠하지 않아도 되는 이유다.colorOnPrimary는 "주인공 색 위에 올라가는" 글자·아이콘의 색이다. 코랄 버튼 위 흰 글자가 여기서 나온다.values-night폴더의 파일은 기기가 다크 모드일 때만 대신 사용된다. 어두운 배경에서는 진한 코랄이 잘 안 보이므로 밝은 변형(brand_coral_light)을 쓴다.- 실행해도 아직 화면 변화가 크지 않다. 다음 절에서 화면 뼈대를 만들면서 한꺼번에 확인한다.
메인 화면의 뼈대 잡기¶
메인 화면의 최종 모습은 "화면 전체를 채운 지도 + 그 위에 떠 있는 버튼 두 개"입니다. 지도는 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>
[설명]
main_welcome이 사라지면서 그 문구를 쓰던 곳이 없어야 한다. 다음 CH 에서 레이아웃을 통째로 바꾸므로 함께 정리된다.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>
[설명]
- 바깥 FrameLayout 안에 두 층이 겹쳐 있다. 아래층은 화면 전체를 채우는 지도 자리(
map_container), 위층은 화면 바닥에 붙는 버튼 줄이다. android:layout_gravity="bottom"이 버튼 줄을 화면 아래쪽에 붙인다. FrameLayout 안에서 각 층의 위치는 이 속성으로 정한다.- 버튼 두 개는
layout_width="0dp"+layout_weight="1"로 되어 있다. LinearLayout 이 남는 가로 공간을 1:1 로 나눠 주라는 뜻으로, 두 버튼이 정확히 반씩 차지한다. - 실행하면 버튼과 상태바가 코랄색이다 — 테마의
colorPrimary가 자동으로 입혀진 것이다. MainActivity 의 자바 코드는 한 줄도 고치지 않았는데 화면이 완전히 달라졌다.
그런데 지도 자리의 배경색 #D8E5DB 를 레이아웃에 직접 적었습니다. 이렇게 하면 어떤 문제가 생기는지, 잠시 뒤 다크 모드 실험에서 직접 확인합니다.
다크 모드를 켜 보면 — 하드코딩 색의 배신¶
[CH-5 아래 과정을 그대로 진행하시오.]¶
- 에뮬레이터에서 설정 > 디스플레이 > 다크 테마 를 켭니다.
- 버킷맵을 다시 엽니다.
실행 결과

버튼과 상태바는 다크 모드용 밝은 코랄로 알아서 바뀌었는데, 화면 대부분을 차지하는 지도 자리는 밝은 연녹색 그대로입니다. 절반만 다크 모드가 된 셈입니다.
[설명]
#D8E5DB처럼 색상 코드를 레이아웃에 직접 적는 것을 하드코딩이라 한다. 하드코딩된 색은 테마가 바뀌어도 절대 변하지 않는다.- 버튼이 알아서 바뀐 이유는 버튼이 색을 직접 들고 있지 않고 "테마의 colorPrimary 를 쓰겠다"고만 해 두었기 때문이다.
- 해결책도 같은 방식이다 — 지도 자리도 색을 직접 들지 말고 테마에게 물어보게 만들면 된다.
[CH-6 아래 코드를 그대로 작성하고 실행하시오.]¶
activity_main.xml 에서 지도 자리의 배경 한 줄만 고칩니다.
android:background="?attr/colorSurfaceVariant">
실행 결과


라이트 모드에서는 은은한 회색, 다크 모드에서는 어두운 회색 — 지도 자리가 테마를 따라다닙니다. 확인이 끝나면 설정 > 디스플레이 > 다크 테마 를 다시 꺼 둡니다.
[설명]
?attr/colorSurfaceVariant는 "지금 적용된 테마의 colorSurfaceVariant 색을 가져와라"라는 뜻이다.@color/가 색 자체를 가리킨다면?attr/는 테마의 색 자리를 가리킨다.colorSurfaceVariant는 Material 테마가 기본으로 갖고 있는 "바탕보다 살짝 진한 면" 색이다. 우리가 직접 정의하지 않아도 라이트/다크 값이 이미 준비되어 있다.- 화면에 보이는 색을 정할 때의 원칙: 브랜드 색은
@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>
[설명]
- 세로 LinearLayout 안에 가로 LinearLayout 이 들어 있다. 레이아웃은 이렇게 상자 안에 상자를 넣어 얼마든지 조합할 수 있다.
- ImageView 의
scaleType="centerCrop"은 사진 비율이 안 맞을 때 가운데를 기준으로 꽉 채워 자르는 방식이다. 6장에서 진짜 장소 사진이 여기 들어온다. - "주소가 여기에 표시됩니다" 같은 문구는 화면 모양을 미리 확인하기 위한 임시 글자다. 코드가 진짜 데이터를 넣는 순간 덮어써진다.
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장에서 만든 Intent 전달이 그대로 살아 있어서, 이름 자리에는 여전히 "떡볶이 맛집"이 표시됩니다. 버튼을 누르면 "아직 준비 중입니다" 안내가 잠깐 떴다 사라집니다.
[설명]
Toast는 화면 아래에 잠깐 떴다가 사라지는 안내 문구다.Toast.makeText(문맥, 문구, 길이).show()세 단계로 쓴다.- 버튼 세 개가 지금은 모두 같은 안내만 띄우지만, 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>
실행 결과
앱을 다시 설치한 뒤 홈 화면에서 위로 쓸어올려 앱 서랍을 엽니다.
![]()
코랄 바탕에 흰 지도 핀, 핀 안에는 "다녀왔다" 체크 — 버킷맵의 정체성이 아이콘 하나에 담겼습니다.
[설명]
pathData는 펜이 움직이는 길을 좌표로 적은 것이다(M = 이동, L = 직선, C/s = 곡선, z = 닫기). 지금 다 읽을 줄 몰라도 전혀 문제없다 — 디자이너가 만든 벡터 파일을 가져다 쓰는 경우가 대부분이다.- 첫 번째 path 는 흰색으로 채운(
fillColor) 핀 모양이고, 두 번째 path 는 코랄색 선으로 그린(strokeColor) 체크 표시다. - 안드로이드 8.0 부터 런처 아이콘은 배경과 전경 두 층을 겹쳐 만든다(어댑티브 아이콘). 기기마다 동그라미·네모 등 다른 틀로 잘라 써도 두 층 구조 덕분에 자연스럽게 보인다. 두 층을 조합하라는 설정은
res/mipmap-anydpi-v26/ic_launcher.xml에 이미 들어 있다.
정리¶
이번 장에서 배운 것을 정리합니다.
- LinearLayout 은 한 방향으로 차곡차곡, FrameLayout 은 같은 자리에 겹겹이 배치한다.
- 색은 colors.xml 에 이름 붙여 모으고, 테마(themes.xml)의 colorPrimary 가 앱 전체에 퍼진다.
- 색을 하드코딩하면 다크 모드가 무시된다 — 테마를 따라야 하는 색은
?attr/로 쓴다. - 런처 아이콘은 배경 + 전경 두 장의 벡터 드로어블로 만든다.
[self Test 1] - 버킷맵의 색을 나만의 팔레트로 바꿔 보시오. colors.xml 의 brand_coral 과 brand_teal 값을 좋아하는 색상 코드로 바꾸고, 다크 모드용 밝은 변형도 어울리게 고른 뒤, 라이트/다크 모드에서 각각 실행해 확인하시오. (색상 코드는 인터넷에서 "color picker" 를 검색하면 쉽게 고를 수 있다.)
[self Test 2] - 상세 화면의 평점 아래에 "영업 중" 이라고 표시되는 TextView 를 하나 추가해 보시오. id 는 text_open, 글자 크기는 14sp, 위쪽 여백은 4dp 로 하시오.