Spinner를 꾸미기 위해서 background속성에 color를 설정하거나 drawable을 작성하여 적용하면 우측 끝에 보이던 화살표(▼)가 사라지는 걸 볼 수 있는데, 이는 기존에 background 속성에 설정된 리소스가 화살표 이미지를 포함하고 있었기 때문에 발생하는 문제다. 이 화살표는 사용자에게 "여기를 누르면 아래 방향으로 메뉴를 펼칠 수 있다"는 것을 알려주는 직관적이고 중요한 요소라고 생각했기에 화살표를 살려보기로 했다.

 

 여러 가지를 시도해 본 결과 화살표를 배치하는 방법은 크게 두 가지가 있었다. 첫 번째는 우격다짐식 해결법이지만 Spinner를 LinearLayout, ConstraintLayout 등과 같은 ViewGroup의 하위에 두고 ViewGroup의 background를 변경하는 것이다. 이 때 Spinner는 하단 라인이나 기본 테두리가 없는 테마를 적용한 상태여야 한다.

 

 

 다른 방법은 Spinner에 적용할 리소스(drawable)에 화살표 이미지를 포함시키는 것이다.

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item>
        <shape android:shape="rectangle">
            <stroke
                android:width="3dp"
                android:color="@color/baltic_sea"/>
            <corners
                android:radius="10dp" />
        </shape>
    </item>

    <item
        android:gravity="right|center_vertical"
        android:width="20dp"
        android:height="20dp"
        android:end="10dp"
        android:drawable="@drawable/baseline_arrow_drop_down_24">
    </item>
</layer-list>

 위와 같이 작성한 xml을 Spinner의 background로 지정해준다. 화살표는 안드로이드 스튜디오 [File - New - Vector Asset]을 통해 생성했기에 같은 이름으로 검색하면 바로 찾을 수 있을 것이다.

 View에 그림자를 넣는 방법은 크게 두 가지가 있다. 하나는 xml에서 그림자 효과를 주려는 뷰에 elevation 속성을 사용하는 것이고 다른 하나는 직접 그림자 효과를 주는 xml을 작성하는 것이다. elevation을 사용하는 방법은 속성 하나만으로 그림자 효과를 낼 수 있어 별도의 구현이 필요없다는 장점이 있지만 그림자 방향이 아래로 고정되어 있으며 방향 변경이 불가능하다는 단점이 있다. 반면 xml로 작성하는 방법은 조금 번거롭더라도 원하는데로 그림자를 커스텀할 수 있다는 장점이 있다.

 

 구현하려는 디자인에 따라 아래로만 그림자를 줘도 충분한 경우가 있는가 하면 그렇지 않은 경우도 있다. 그림자가 퍼지는 방향을 조절하거나 사방으로 퍼지게 하기, 내부 그림자 만들기 등 자유롭게 커스텀하고 싶은 경우 직접 레이아웃을 작성해야 한다. 이 포스팅에서는 그림자를 넣는 방법, 관련 속성, 몇 가지 예제를 다룰 것이다. 

 

 그림자를 적용할 때 주의해야 할 것은 이 리소스가 적용되는 View의 Parent View는 투명하지 않아야 한다는 것이다. 또한 그림자가 바깥을 향할 경우 View의 Parent View는 그림자의 색을 식별할 수 있을 정도로 밝아야 한다. 특히나 프로젝트에 적용한 테마의 배경색이 블랙 계열일 경우 그림자 적용 여부를 육안으로 확인할 수 없으므로 주의해야 한다. 나는 처음 그림자를 적용할 때 검은 배경 위에서 View의 그림자를 적용하려는 멍청한 짓을 해서 한참을 삽질한 경험이 있다.

 

 

 

그림자 그리기에 사용 가능한 속성

 그림자를 직접 그릴 때 그림자 효과를 주는 옵션은 존재하지 않는다. 그림자는 그라데이션 효과를 통해 그려지는 것이고 그려질 방향과 크기까지 모두 직접 설정해야 한다.

  • item : 레이어 역할을 하는 옵션이다. 이 옵션 안에 표시되는 것들은 하나의 레이어에 그려진다고 보면 된다. 먼저 선언한 속성이 가장 아래로, 나중에 선언한 속성이 가장 위에 표시된다.
  • angle : 그라데이션이 그려질 각도를 지정한다. (90 = to top, 180 = to start,  270 = to bottom, 360 = to end)
  • startColor : 그라데이션의 시작 색상을 지정한다.
  • centerColor : 그라데이션의 중간 색상을 지정한다.
  • endColor : 그라데이션의 끝 색상을 지정한다.
  • centerX : 좌, 우에서 그라데이션을 어디까지 그릴지 결정한다. (0.1 ~ 1.0이 들어갈 수 있으며 0.1 = 10%라고 생각하면 된다.)
  • centerY : 상, 하에서 그라데이션을 어디까지 그릴지 결정한다.

 포토샵을 사용해본 사람이라면 item 속성을 레이어라고 이해하면 된다. 배경을 그리거나 특정 방향에서 뻗어 나오는 그림자를 그릴 때마다 item 속성이 하나씩 사용될 것이다.

 

 그림자처럼 보일 그라데이션을 그리는데 사용할 속성은 startColor, centerColor, endColor다. 시작, 중간, 마지막 색상을 각각 검정, 회색, 배경색으로 지정하여 그라데이션을 그려준다.

 

 그림자를 어느 방향으로 그릴지 결정하는 것은 angle이다. top, bottom, left, right 방향으로 그리는 것이 일반적이다.

 

 그림자를 얼마나 그릴지를 결정하는 것은 centerY, centerX다. 이 값은 말로 설명하기 보다 0.01~0.99 까지 값을 바꿔 넣어가며 결과를 눈으로 확인하는 것이 빠를 것다.

 

 만약 생성하려는 리소스가 둥근 모서리, 즉 rdius 옵션을 갖는다면 사용하는 모든 item 옵션에 dadius를 설정해야 한다.

 

 

 

Examples

 내부 그림자 넣기

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:top="1dp"
        android:left="1dp"
        android:right="1dp"
        android:bottom="1dp">
        <shape android:shape="rectangle">
            <solid android:color="@color/white"/>
            <corners android:radius="8dp"/>
        </shape>

    </item>

    <item>
        <shape android:shape="rectangle">
            <gradient
                android:angle="180"
                android:centerColor="#00FF0000"
                android:centerX="0.9"
                android:endColor="#5C000000"
                android:startColor="#00FF0000" />
            <corners android:radius="8dp" />
        </shape>
    </item>

    <item>
        <shape android:shape="rectangle">
            <gradient
                android:angle="360"
                android:centerColor="#00FF0000"
                android:centerX="0.9"
                android:endColor="#5C000000"
                android:startColor="#00FF0000" />
            <corners android:radius="8dp" />
        </shape>
    </item>

    <item>
        <shape android:shape="rectangle">
            <gradient
                android:angle="270"
                android:centerColor="#00FF0000"
                android:centerY="0.1"
                android:endColor="#00FF0000"
                android:startColor="#6B000000" />
            <corners android:radius="8dp" />
        </shape>
    </item>

    <item>
        <shape android:shape="rectangle">
            <gradient
                android:angle="90"
                android:centerColor="#00FF0000"
                android:centerY="0.1"
                android:endColor="#00FF0000"
                android:startColor="#6B000000" />
            <corners android:radius="8dp" />
        </shape>
    </item>
</layer-list>

 

 내부 그림자를 가지면서 모서리가 둥근 배경을 만드는 샘플이다. 배경이 될 첫 번째 item, 두 번째 item부터는 사방의 그림자를 그리는 역할을 하며 첫 번째 item을 제외한 나머지 item들을 모두 투명한 배경으로 설정하였다.

 

 앞서 말했듯 그림자는 그라데이션을 그려서 그림자처럼 보이게 만드는 것이다. 처음은 검정색, 중간은 회색, 마지막은 흰색(배경색)으로 그라데이션을 그려 그림자가 그린 것을 볼 수 있다.

 안드로이드 어플리케이션에서 하단에 네비게이션을 두고 화면을 이동할 수 있는 BottomNavigationView를 구현하는 방법에 대한 기록 포스팅이다. 스크롤의 유지 여부를 확인하기 위해서 각 프래그먼트에는 컬러 TextView가 배치되어 있다.

 

 

 

1. 프로젝트 생성

 프로젝트를 생성할 때 보면 메뉴에 "Bottom Navigation Views Activity"가 존재하는데 버전이나 언어에 따라 다를 수 있겠지만 나는 이걸로 생성하면 오류가 정말 많이 났다. 처음에 바텀 네비게이션을 구현할 때 이걸로 프로젝트를 만들어 기본 코드를 분석하려 했는데 기본 코드임에도 불구하고 갖은 오류가 발생해 빌드조차 되지 않았었다. 그래서 나는 빈 프로젝트에 바텀 네비게이션을 구현하는 것에 더 익숙하기에 빈 프로젝트로 예제를 시작할 것이다.

 

 

 

2. build.gradle에 의존성 주입 확인

dependencies {
    ..
    implementation 'com.google.android.material:material:1.9.0'
    implementation 'androidx.navigation:navigation-fragment:2.6.0'
    implementation 'androidx.navigation:navigation-ui:2.6.0'
    ..
}

 "com.google.android.material"의 경우 어느 시점부터 프로젝트를 만들면 자동으로 등록되기 시작했기에 버전에 따라 기본적으로 있을 수도 있고 없을 수도 있다. 없을 경우 직접 추가해주면 된다.

 

 추가로 아래 "androidx.navigation*" 두 개를 추가해준다. material은 바텀 네비게이션 자체를 사용하기 위함이었다면 아래 두 개는 바텀 네비게이션으로 프래그먼트를 제어하기 위해 추가한 것이다.

 

 

 

3. menu 생성하기

 바텀 네비게이션의 각 항목(이하 메뉴)에 표시할 이름 리소스 파일을 작성한다.

 

 먼저 [res 폴더 우클릭 → New → Android Resource Directory] 에서 "menu"를 입력해 디렉터리를 생성한다. 그리고 [menu 폴더 우클릭 → New → Menu Resource File]을 통해 메뉴를 작성할 파일을 생성한다.

 

 참고로 "res/navigation" 폴더를 생성하여 만드는 방법도 있는데 그 경우 버전이 높은 navigation에 대한 의존성을 따로 주입해줘야 한다. 최신 버전이긴 하지만 그에 따라 컴파일 버전도 손을 봐야하니 잘 모르면 그냥 넘어가는 것이 좋다.

 

 

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/menu_main_bottom_home"
        android:enabled="true"
        android:icon="@drawable/baseline_home_24"
        android:title="홈"/>
    <item
        android:id="@+id/menu_main_bottom_chat"
        android:enabled="true"
        android:icon="@drawable/baseline_mode_comment_24"
        android:title="대화"/>
    <item
        android:id="@+id/menu_main_bottom_search"
        android:enabled="true"
        android:icon="@drawable/baseline_search_24"
        android:title="검색"/>
    <item
        android:id="@+id/menu_main_bottom_setting"
        android:enabled="true"
        android:icon="@drawable/baseline_settings_24"
        android:title="설정"/>
</menu>

 이 파일은 루트 태그로 <menu>를 가지며 <item> 태그로 각 항목을 생성한다. 각 아이템에는 식별을 위한 id, 메뉴 활성화 여부, 표시할 아이콘, 표시할 텍스트 등을 지정할 수 있다.

 

 여기서 사용된 이미지 아이콘은 모두 안드로이드 스튜디오의 Vector Asset을 통해 생성한 것을 사용하였다.

 

 

4. *.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"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/activity_main_container_view"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toTopOf="@id/activity_main_bottom_navigation_view"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/activity_main_bottom_navigation_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:labelVisibilityMode="labeled"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintTop_toBottomOf="@id/activity_main_container_view"
        app:menu="@menu/main_bottom_nav" />

</androidx.constraintlayout.widget.ConstraintLayout>

 바텀 네비게이션을 적용할 layout xml을 작성한다. 화면에는 단 두 개의 뷰만 존재하는 것을 볼 수 있는데 하나는 Fragment를 교체하면서 보여줄 FragmentContainerView이고 나머지 하나는 표시할 Fragment를 결정할 BottomNavigationView다. 

 

 BottomNavigationView에 사용된 속성을 보면 menu 속성을 통해 앞서 생성한 리소스를 적용한 것을 볼 수 있다. 또 사용된 옵션 중에는 labelVisibilityMode가 있는데 이 옵션은 네비게이션에 표시되는 항목에 대해 이미지만 보일 것인지, 텍스트(Label)도 같이 표시할 것인지 등을 지정할 수 있다. 

 

labelVisibilityMode에 사용 가능한 값

  • auto : 항목이 3개 이하일 경우 labbeled, 4개 이상일 경우 selected
  • labeled : 텍스트를 항상 표시
  • selected : 선택된 항목에 대해서만 텍스트를 표시
  • unlabeled : 텍스트를 항상 숨김

 

 

 

5. Fragment 생성하기

 바텀 네비게이션을 통해 ContainerView에 표시할 Fragment를 네비게이션 항목 수에 맞게 생성한다. 나는 4개의 항목을 생성했으므로 총 4개를 생성했는데 예제인 만큼 구분이 편하도록 이름은 넘버링을 붙여 생성했다.

 

public class FirstFragment extends Fragment {

    public static FirstFragment newInstance() {
        return new FirstFragment();
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_first, container, false);
    }
}

 구현할 기능은 딱히 없으므로 객체를 생성하는 newInstance 메서드만 존재한다.

 

 

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

    <androidx.appcompat.widget.LinearLayoutCompat
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <androidx.appcompat.widget.AppCompatTextView
            android:layout_width="match_parent"
            android:layout_height="300dp"
            android:background="@color/yellow"
            android:gravity="center"
            android:text="Fragment 1-1"
            android:textColor="@color/black" />

        <androidx.appcompat.widget.AppCompatTextView
            android:layout_width="match_parent"
            android:layout_height="400dp"
            android:background="@color/turquoise"
            android:gravity="center"
            android:text="Fragment 1-2"
            android:textColor="@color/black" />

        <androidx.appcompat.widget.AppCompatTextView
            android:layout_width="match_parent"
            android:layout_height="400dp"
            android:background="@color/silver"
            android:gravity="center"
            android:text="Fragment 1-3"
            android:textColor="@color/black" />

        <androidx.appcompat.widget.AppCompatTextView
            android:layout_width="match_parent"
            android:layout_height="400dp"
            android:background="@color/turquoise"
            android:gravity="center"
            android:text="Fragment 1-4"
            android:textColor="@color/black" />

        <androidx.appcompat.widget.AppCompatTextView
            android:layout_width="match_parent"
            android:layout_height="400dp"
            android:background="@color/yellow"
            android:gravity="center"
            android:text="Fragment 1-5"
            android:textColor="@color/black" />

    </androidx.appcompat.widget.LinearLayoutCompat>

</ScrollView>

 스크롤을 확인하기 위해 ScrollView를 배치하고 내부에 TextView를 5개 배치하였다. 화면이 이동되는 와중에 스크롤이 유지되는지 확인하기 위함이다. 프래그먼트 4개의 레이아웃은 모두 같으며 TextView의 text만 다르다.

 

 

 

Fragment 띄우기

public class MainActivity extends AppCompatActivity {
    private ActivityMainBinding binding;

    private FirstFragment fragment1;
    private SecondFragment fragment2;
    private ThirdFragment fragment3;
    private FourthFragment fragment4;

    private final String TAG = "TAG_MainActivity";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        setupBottomNavigation();
    }

    private void setupBottomNavigation() {
        BottomNavigationView bottomNav = binding.activityMainBottomNavigationView;
        addFragment(fragment1 = FirstFragment.newInstance());

        bottomNav.setOnItemSelectedListener(item -> {
            int selectedItemId = item.getItemId();

            if (selectedItemId == R.id.menu_main_bottom_chat) {
                if (fragment2 == null)
                    addFragment(fragment2 = SecondFragment.newInstance());
            } else if (selectedItemId == R.id.menu_main_bottom_search) {
                if (fragment3 == null)
                    addFragment(fragment3 = ThirdFragment.newInstance());
            } else if (selectedItemId == R.id.menu_main_bottom_setting) {
                if (fragment4 == null)
                    addFragment(fragment4 = FourthFragment.newInstance());
            }

            replaceFragment(selectedItemId);
            return true;
        });
    }

    private void addFragment(Fragment fragment) {
        getSupportFragmentManager()
                .beginTransaction()
                .add(binding.activityMainContainerView.getId(), fragment)
                .commit();
    }

    private void replaceFragment(int selectedMenuId) {
        FragmentTransaction fragmentTransaction = getSupportFragmentManager()
                .beginTransaction();

        if (selectedMenuId == R.id.menu_main_bottom_home)
            fragmentTransaction.show(fragment1);
        else
            fragmentTransaction.hide(fragment1);

        if (selectedMenuId == R.id.menu_main_bottom_chat)
            fragmentTransaction.show(fragment2);
        else if (fragment2 != null)
            fragmentTransaction.hide(fragment2);

        if (selectedMenuId == R.id.menu_main_bottom_search)
            fragmentTransaction.show(fragment3);
        else if (fragment3 != null)
            fragmentTransaction.hide(fragment3);

        if (selectedMenuId == R.id.menu_main_bottom_setting)
            fragmentTransaction.show(fragment4);
        else if (fragment4 != null)
            fragmentTransaction.hide(fragment4);

        fragmentTransaction.commit();
    }
}

 Fragment를 표시할 Container View를 가진 MainActivity다.

 

 

 Fragment들은 모두 멤버 변수로 선언하였다. 메뉴의 항목이 눌릴 때 마다 매번 새로운 프래그먼트를 만들어줄 것이 아니라면 굳이 멤버 변수로 생성하지 않을 이유가 없다.(배열로 관리해도 된다.)

 

 

 화면이 초기화되는 시점에서는 컨테이너에 최초로 표시되는 프래그먼트가 있어야 하므로 FirstFragment를 컨테이너에 추가한다. 그리고 네비게이션의 각 항목이 선택될 때마다 이벤트를 처리할 리스너를 작성한다. 특정 항목이 선택되면 먼저 선택된 항목이 최초로 선택된 것인지 확인(== null)하여 최초 선택이 아닐 경우 객체를 초기화하면서 컨테이너에 프래그먼트를 추가한다.

 

 조건문까지는 모든 프래그먼트를 최초 1회 실행시켜주는 구문이었다. 그럼 이제 프래그먼트가 재차 눌렸을 때 프래그먼트를 다시 표시하는, 즉 컨테이너의 프래그먼트를 전환해야 하는데 조건문 이후 호출되는 replaceFragment 메서드가 그 역할을 한다.

 

 replaceFragment에서 볼 코드는 show/hide 메서드를 호출하는 부분이다. 컨테이너에 모든 프래그먼트가 추가된 상태에서 특정 프래그먼트만을 보여주기 위해 나머지 모든 프래그먼트를 hide 상태로 만든다.

 

 

 결과를 확인하면 프래그먼트 이동 시 이전 프래그먼트에서 발생한 스크롤이 그대로 유지되어 있는 것을 볼 수 있다.

+ Recent posts