제목과 상세 내용으로 나뉘어진 Fragment 예제. (Titles and Details Fragment Example)

2016. 7. 9. 16:45


1. 다양한 화면 크기 및 화면 모드를 위한 Fragment

앞서 작성한 Fragment 관련 글과 예제를 통해 Fragment의 개념 및 기본 사용법, FragmentManager, ListView를 가지는 ListFragment로의 확장 등에 대해 살펴보았습니다. 또한 Fragment의 장점으로는 모듈 단위의 UI 구성, 이미 만들어 놓은 Fragment의 재사용, 다양한 화면 크기 및 화면 모드에서에서의 유연한 동작 등이 있다는 것도 설명하였습니다.


그런데 여러 장점들 중에서 "다양한 화면 크기 및 화면 모드에서의 유연한 동작"에 대한 내용은 충분히 설명되지 않은 것 같네요. "다양한 화면 크기 및 화면 모드에서의 유연한 동작"이라는 장점을 경험하기 위해서는 어떤 작업을 거쳐야 하는 걸까요?


자, 지금 부터 간단한 앱을 하나 만드는 것을 가정해 보겠습니다. 아주 간단합니다. 제목 리스트를 표시하고, 리스트의 제목 중 하나를 선택하면 그에 해당하는 상세내용을 표시하는 앱입니다.


앱이 모바일 폰에서 동작하는 모습을 구성해보자면, 아래와 같은 구성도를 가지게 될 것입니다.

제목과 상세 내용 프래그먼트

그런데 모바일 폰을 항상 세로 모드로만 사용하는 것은 아니죠. 만약 가로로 놓고 쓴다면 아래와 같이 표시될 것입니다.

제목과 상세 내용 프래그먼트


1.1 태블릿에서의 실행

이제 위에서 만든 앱을 모바일 폰이 아닌, 큰 화면을 가진 태블릿에서 실행해 보겠습니다.

제목과 상세 내용 프래그먼트 태블릿

모바일 폰에서 실행한 것에 비교하여 여백이 상당히 많이 보이는 것을 확인할 수 있습니다. 흠.. 뭐.. 괜찮네요. 이제 태블릿을 가로 모드로 바꿔 보겠습니다.

제목과 상세 내용 프래그먼트 태블릿


흠.. 당연히 세로 모드에 비해 좌우의 공간이 많이 비어 있는 것을 확인할 수 있습니다.


이 시점에서 한 가지 고민을 해봅시다. "저 넓은 태블릿 화면에서, 과연 제목과 상세내용을 따로 표시할 필요가 있을까? 모바일 폰은 화면 크기의 제약 때문에 어쩔 수 없다 하더라도 태블릿의 가로 모드에서는 제목과 상세내용을 한번에 표시하는 게 더 좋지 않을까?" 라고 말이죠. 바로 다음 화면에서 보여지는 내용 처럼 말입니다.

제목과 상세 내용 프래그먼트 사용 예제


2. 제목과 상세내용으로 나뉘어진 Fragment 예제

제목(Titles)과 상세내용(Details)로 나뉘어진 Fragment에 대한 예제는 구글에서 제공하는 안드로이드 예제 소스에 포함되어 있으며, 예제에 대한 설명은 "https://developer.android.com/guide/components/fragments.html?hl=ko#Example"에 잘 나와 있습니다. 이 글의 예제도 안드로이드에서 제공하는 예제를 바탕으로 작성되었기 때문에, 구글의 프래그먼트 예제에 대한 설명을 같이 보시면 훨씬 많은 도움이 될 것입니다.


그럼 지금부터 예제를 만들어 보도록 하겠습니다.

2.1 워크 플로우

제목과 상세 내용 프래그먼트 작성 절차


2.2 Activity에 Fragment 추가.

Activity에 Fragment를 표시하기 위해 Layout 리소스 XML 파일에 다음과 같은 내용을 추가합니다. "android:name"에는 화면에 출력될 Fragment 클래스를 지정하며, 예제의 클래스는 "2.4 Fragment 상속 및 구현" 단계에서 추가될 클래스입니다.


앱이 실행될 때 최초에는 제목 리스트만 표시될 것입니다. 그래서 아래의 Layout 리소스 XML 내용에는 TitlesFragment만 기술되어 있습니다.

[STEP-1.1] "content_main.xml" - MainActivity에 TitlesFragment 추가.
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context="com.recipes4dev.examples.fragmenttitlesdetailsexample1.MainActivity"
    tools:showIn="@layout/activity_main">

    <fragment
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/titles_fragment"
        android:name="com.recipes4dev.examples.fragmenttitlesdetailsexample1.TitlesFragment"
        tools:layout="@layout/activity_main" />

</RelativeLayout>

다른 예제들에선 하나의 Layout 리소스 XML만 작성하고 다음 단계로 넘어갔지만, 여기서는 추가적인 작업을 해줘야 합니다. 왜냐하면 "1.1 태블릿에서의 실행"에서 다양한 화면 크기 및 화면 모드에 대한 요구가 언급되었기 때문이죠. 이를 위해 "태블릿 화면 크기에서의 가로 모드"를 위한 Layout 리소스 XML을 별도의 파일로 정의해야 하며, 제목이 표시되는 Fragment와 상세내용이 표시되는 Fragment 모두 하나의 Layout 리소스 XML에 배치되어야 합니다.


태블릿(7인치 이상)에서의 가로 모드를 위한 Layout 리소스 XML을 추가하기 위해서는 "/res/layout-large-land/"에 XML 파일을 추가하면 됩니다.

    "/res/layout-large-land/content_main.xml" 파일 추가.

large layout 추가


추가된 파일은 "/res/layout" 아래에 다음과 같이 표시됩니다. "layout-"이라는 prefix는 생략되고 "large-land"만 남아 있는 것을 주의하세요.

    프로젝트 파일 리스트에서 "/res/layout-large-land/content_main.xml"의 표시 내용.

large-land 리소스


다양한 화면 크기를 지원하기 위한 방법에 대한 설명은 많은 내용이 포함되기 때문에, 별도의 주제로 다루겠습니다. 그리고 구글 Training 문서("https://developer.android.com/training/multiscreen/screensizes.html")에 친절하게 설명되어 있으므로 읽어보시길 바랍니다.

새로 추가한 "/res/layout-large-land/content_main.xml" 파일에 아래의 내용을 작성합니다.

[STEP-1.2] "/res/layout-large-land/content_main.xml" - TitlesFragment와 DetailsFragment를 위한 Layout 리소스 정의.
<?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"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    android:orientation="horizontal"
    tools:context="com.recipes4dev.examples.fragmenttitlesdetailsexample1.MainActivity"
    tools:showIn="@layout/activity_main">

    <fragment
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:id="@+id/titles_fragment"
        android:name="com.recipes4dev.examples.fragmenttitlesdetailsexample1.TitlesFragment" />

    <FrameLayout
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="2"
        android:id="@+id/details_container" />

</LinearLayout>

2.3 Fragment를 위한 Layout 리소스 XML 정의

제목 리스트가 표시될 Fragment는 [ListView를 가지는 Fragment 만들기]에서 살펴본 ListFragment로부터 상속받아 사용하기 때문에 별도의 Layout 리소스 XML을 정의하지 않습니다. 하지만 상세내용이 표시되는 Fragment는 예제 구성도에 따라 두 개의 TextView를 가지는 Layout 리소스 XML을 정의해야 합니다.


상세내용을 위한 Fragment의 Layout 리소스 XML은 "/res/layout/details_fragment.xml"이라는 이름으로 추가하겠습니다.

[STEP-2] "/res/layout/details_fragment.xml" - DetailsFragment를 위한 Layout 리소스 XML.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    android:orientation="vertical" android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/selectedTitle"
        android:textSize="32sp"
        android:background="#2ECC71"
        android:layout_weight="1"
        android:gravity="center"
        android:text="TextView for TITLE"/>

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:id="@+id/detailsText"
        android:textSize="20sp"
        android:background="#F1C40F"
        android:layout_weight="9"
        android:text="TextView for DETAILS" />

</LinearLayout>

2.4 Fragment 상속 및 구현

앞선 단계들에서 Layout 리소스 XML에 대한 작성은 완료하였으므로 이제 각 Fragment들에 대한 클래스를 정의해야 합니다. 잠시 언급한대로 제목 리스트가 표시되는 Fragment는 ListFragment로부터 상속받습니다.

[STEP-3.1] "TitlesFragment.java" - TitlesFragment 추가.
public class TitlesFragment extends ListFragment {
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        {
            return super.onCreateView(inflater, container, savedInstanceState) ;
        }
    }
}

그리고 상세 내용이 표시되는 DetailsFragment는 Fragment로부터 상속받습니다.

[STEP-3.2] "DetailsFragment.java" - DetailsFragment 추가.
public class DetailsFragment extends Fragment {

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.details_fragment, container, false);
        super.onCreateView(inflater, container, savedInstanceState);

        // TODO : TextView에 제목 및 상세 내용 표시.

        return view ;
    }
}

DetailsFragment의 onCreateView() 함수의 내용을 보면 "R.layout.details_fragment"를 inflate한 다음, 리턴되는 view에 대한 참조를 따로 유지하고 있습니다. 이는 이후 과정에서 view를 사용하여 Fragment 내부의 TextView 참조를 획득한 다음, 제목 및 상세 내용을 표시하기 때문입니다.

2.5 DetailsActivity 구현

예제에서 7인치 이상의 태블릿 가로 모드에서는 두 종류의 Fragment를 나란히 표시하지만, 그 외의 실행환경에서는 상세 내용을 출력하기 위해 Activity를 사용하도록 구현하겠습니다. 이를 위해 DetailsActivity라는 이름으로 Activity를 추가합니다. (반드시 Activity를 사용해야만 하는 이유가 있는 것은 아닙니다. 단지, 예제 코드 작성의 편의성 및 다양성을 위해 Activity를 사용한 것일 뿐입니다.)

[STEP-4] "DetailsActivity.java" - DetailsActivity 추가.
import android.content.Intent;
import android.os.Bundle;
import android.support.design.widget.FloatingActionButton;
import android.support.design.widget.Snackbar;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.Toolbar;
import android.view.View;
import android.widget.TextView;

public class DetailsActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        setContentView(R.layout.details_fragment);

        Intent intent = getIntent() ;

        TextView titleTextView = (TextView) findViewById(R.id.selectedTitle) ;
        titleTextView.setText(intent.getExtras().getString("title")) ;

        TextView detailsTextView = (TextView) findViewById(R.id.detailsText) ;
        detailsTextView.setText(intent.getExtras().getString("details")) ;
    }
}

새로운 DetailsActivity 클래스를 추가하긴 했지만 별도의 Layout 리소스 XML을 만들진 않았습니다. 대신, 이전 단계에서 만들어 놓은 "details_fragment.xml"을 setContentView() 함수에 지정하여 DetailsFragment의 Layout 리소스 XML을 재사용하고 있습니다. 또한 선택된 제목 및 상세 내용은 Intent로 전달받아 각 TextView에 표시하고 있습니다.

2.6 TitlesFragment의 ListView에 항목 추가

TitlesFragment의 내부에서 Adapter를 생성할 수도 있지만, 일단 제목 및 상세내용 정보가 Activity에서 관리되도록 만들겠습니다. 단, 예제 코드의 복잡성을 높이지 않기 위해 제목과 상세내용은 배열로 저장하고, 그 중 제목들만 Adapter에 추가하겠습니다.

[STEP-5] "MainActivity.java" - onCreate() 함수에서 TitlesFragment에 Adapter 적용.
public class MainActivity extends AppCompatActivity {}
    final String[][] contents = new String[3][2] ;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        // 코드 계속 ...

        contents[0][0] = "TITLE-1" ;
        contents[0][1] = "This is Details of TITLE-1." ;
        contents[1][0] = "TITLE-2" ;
        contents[1][1] = "This is Details of TITLE-2." ;
        contents[2][0] = "TITLE-3" ;
        contents[2][1] = "This is Details of TITLE-3." ;

        ArrayAdapter adapter = new ArrayAdapter(this, android.R.layout.simple_list_item_1, new ArrayList()) ;
        adapter.add(contents[0][0]) ;   // add "TITLE-1"
        adapter.add(contents[1][0]) ;   // add "TITLE-2"
        adapter.add(contents[2][0]) ;   // add "TITLE-3"

        TitlesFragment titlesFragment = (TitlesFragment) getSupportFragmentManager().findFragmentById(R.id.titles_fragment);
        titlesFragment.setListAdapter(adapter) ;

        // ... 코드 계속
    }
}

2.7 Activity에서 Fragment 초기화

모바일 폰에서 앱이 처음 실행되면 Activity의 Layout 리소스 XML의 "titles_fragment"에 지정된 TitlesFragment가 표시되므로 별다른 초기화 작업이 필요하지 않습니다. 하지만 7인치 이상의 태블릿의 가로 모드에서 앱이 실행되면 TitlesFragment 뿐만 아니라, "details_container"에 DetailsFragment도 표시해야 하기 때문에 추가적인 초기화 과정이 수행되어야 합니다.


Fragment에 대한 초기화 과정은 [안드로이드 프래그먼트 기본 사용법]에서 설명했듯이 FragmentManager를 사용하여 수행할 수 있습니다.


단, "7인치 이상의 화면 크기에서 가로 모드"라는 조건인 경우에만 DetailsFragment의 초기화 과정을 실행하도록 코드를 작성합니다.

[STEP-6] "MainActivity.java" - onCreate() 함수에서 DetailsFragment 초기화.
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {

        // 코드 계속 ...

        if (getResources().getConfiguration().isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE) &&
                getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE)
        {
            DetailsFragment fr = new DetailsFragment();
            Bundle args = new Bundle() ;

            args.putString("title", contents[0][0]);
            args.putString("details", contents[0][1]);
            fr.setArguments(args) ;

            FragmentManager fm = getSupportFragmentManager();
            FragmentTransaction fragmentTransaction = fm.beginTransaction();
            fragmentTransaction.replace(R.id.details_container, fr);
            fragmentTransaction.commit();
        }
    }
}

현재 기기 상태에 대한 정보를 확인하기 위해 getResource().getConfiguration() 함수를 사용하였습니다. 여기서 리턴되는 Configuration 클래스에는 현재 기기의 모든 설정 정보가 저장되어 있으며, 멤버 함수 또는 변수를 통해 설정 정보를 조회할 수 있습니다.


Configuration.isLayoutSizeAtLeast() 함수는 현재 기기가 제공하는 최소 화면 크기를 검사할 때 사용될 수 있습니다. 그리고 Configuration.orientation 변수는 세로 모드(PORTRAIT)인지 가로 모드(LANDSCAPE)인지 정보를 가지고 있습니다.


예제의 if 조건에 기술된 내용은 기기가 최소 7인치 이상(SCREENLAYOUT_SIZE_LARGE)이고, 가로 모드(ORIENTATION_LANDSCAPE)인지 검사하는 코드입니다. 만약 그렇다면 앱이 실행됨과 동시에 DetailsFragment의 인스턴스를 생성하여 화면(details_container)에 표시합니다.

2.8 TitlesFragment의 제목 클릭 시, DetailsFragment 변경.

이제 예제 작성의 마지막 단계로 TitlesFragment의 ListView에 표시된 제목을 선택하면, 선택된 제목에 대한 상세 내용이 DetailsFragment에 표시되도록 만들겠습니다. 이 때, ListView의 제목을 선택했을 때의 이벤트 처리는 TitlesFragment에서 onListItemClick() 함수를 override하여 처리합니다.

[STEP-7.1] "TitlesFragment.java" - onListItemClick() 함수 override.
public class TitlesFragment extends ListFragment {

    // 코드 계속 ...

    @Override
    public void onListItemClick(ListView l, View v, int position, long id) {
        showDetails(position) ;
    }

    // ... 코드 계속.
}

다음으로 선택된 제목에 해당하는 상세 내용을 표시하기 위해 MainActivity에서 position을 전달받아 DetailsFragment를 표시하는 과정을 구현하겠습니다. 이를 위해 TitlesFragment에 이벤트 콜백을 위한 인터페이스를 정의한 다음, MainActivity에서 해당 인터페이스를 구현해야 합니다. 이는 Fragment에서 Activity로 이벤트를 전달하는 일반적인 방법입니다.


먼저 TitlesFragment에 이벤트를 위한 인터페이스를 정의합니다.

[STEP-7.2] "TitlesFragment.java" - 이벤트 콜백 인터페이스 정의
public class TitlesFragment extends ListFragment {

    public interface OnTitleSelectedListener {
        public void onTitleSelected(int position) ;
    }

    OnTitleSelectedListener titleSelectedListener;

    // 코드 계속 ...

    // onListItemClick() 함수에서 호출하는 showDetails() 함수.
    public void showDetails(int position) {
        titleSelectedListener.onTitleSelected(position) ;
    }

    // 코드 계속 ...
}

다음 TitlesFragment가 MainActivity에 Attach될 때, MainActivity에 구현된 인터페이스 리스너를 참조할 수 있도록 만듭니다.

[STEP-7.3] "TitlesFragment.java" - TitlesFragment의 onAttach()에서 콜백 참조 저장.
public class TitlesFragment extends ListFragment {

    // 코드 계속 ...

    @Override
    public void onAttach(Context context) {
        super.onAttach(context);
        try {
            if (context instanceof Activity) {
                titleSelectedListener = (OnTitleSelectedListener) context;
            }
        } catch (ClassCastException e) {
            throw new ClassCastException(context.toString() + " must implement OnTitleSelectedListener");
        }
    }

    // ... 코드 계속
}

이제 MainActivity에서 TitlesFragment의 OnTitleSelectedListener를 implements합니다. 또한 onTitleSelected() 함수에서 DetailsFragment의 내용을 표시합니다. 이 때 화면 크기 및 모드 조건에 따라 Activity를 실행할 지, Fragment를 만들지 결정합니다.

[STEP-7.4] "MainActivity.java" - 이벤트 콜백 인터페이스 구현.
public class MainActivity extends AppCompatActivity implements TitlesFragment.OnTitleSelectedListener {

    // 코드 계속 ...

    @Override
    public void onTitleSelected(int position)
    {
        if (getResources().getConfiguration().isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE) &&
                getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
            DetailsFragment fr = new DetailsFragment();
            Bundle args = new Bundle() ;

            args.putString("title", contents[position][0]);
            args.putString("details", contents[position][1]);
            fr.setArguments(args) ;

            FragmentManager fm = getSupportFragmentManager();
            FragmentTransaction fragmentTransaction = fm.beginTransaction();
            fragmentTransaction.replace(R.id.details_container, fr);
            fragmentTransaction.commit();
        } else {
            Intent intent = new Intent();
            intent.setClass(this, DetailsActivity.class);

            intent.putExtra("title", contents[position][0]);
            intent.putExtra("details", contents[position][1]);

            startActivity(intent);
        }
    }

    // ... 코드 계속
}

위의 예제 코드를 보면 Fragment에 데이터를 전달할 때 Bundle의 인스턴스를 생성하여 데이터를 채운 다음, setArguments() 함수를 통해 인스턴스를 전달하는 것을 확인할 수 있습니다. Intent를 사용하여 Activity에 데이터를 전달하는 방법과 비슷하죠. Fragment의 setArguments() 함수로 전달한 데이터는 getArguments() 함수를 사용하여 가져올 수 있습니다.


아래 코드는 DetailsFragment 내에서 getArguments() 함수를 사용하여 TextView에 데이터를 표시하는 코드입니다.

[STEP-7.5] "DetailsFragment.java" - DetailsFragment에서 제목 및 상세 내용 표시.
public class DetailsFragment extends Fragment {

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.details_fragment, container, false);
        super.onCreateView(inflater, container, savedInstanceState);

        Bundle args = getArguments() ;

        TextView titleTextView = (TextView) view.findViewById(R.id.selectedTitle) ;
        titleTextView.setText(args.getString("title")) ;

        TextView detailsTextView = (TextView) view.findViewById(R.id.detailsText) ;
        detailsTextView.setText(args.getString("details")) ;

        return view ;
    }
}

3. Fragment 예제 실행 화면

아래는 모바일 폰(갤럭시 노트 2)에서 세로 모드로 실행한 화면입니다.

제목과 상세 내용 프래그먼트 예제 실행 화면


다음은 모바일 폰(갤럭시 노트 2)에서 가로 모드로 실행한 화면입니다.

제목과 상세 내용 프래그먼트 예제 실행 화면


이제 7인치 크기를 가진 넥서스 7 2세대에서의 실행 결과를 보겠습니다. 세로 모드에서의 실행화면입니다.

제목과 상세 내용 프래그먼트 예제 실행 화면


마지막으로 7인치 크기의 넥서스 7 2세대에서 가로 모드로 실행한 화면입니다. TitlesFragment와 DetailsFragment가 한 화면에 동시에 표시되는 것을 확인할 수 있습니다.

제목과 상세 내용 프래그먼트 예제 실행 화면


4. 참고.

.END.


ANDROID 프로그래밍/FRAGMENT