Fragment 왜? Fragment를 사용하는지 자체에 대한 의문이 생길것입니다
단지 UI 구성 요소를 묶으려면 그냥 ViewGroup이나 재사용 가능한 XML Layout정의를 만들면 되지않을까요? 물론 이것도 가능합니다
하지만 Fragment는 단순히 UI구성요소를 묶는 정도에 그치지 않습니다 Fragment는 유지 중인 앱 상태에서 수신하는 Lifecycle Event를 포함한 Activity을 완전히 모듈화 하도록 해 줍니다
Fragments는 허니콤(Android 3.0)에서 특정 문제를 해결하기 위해서 처음 도입되었습니다 최초로 Tablets을 지원했기때문에 좋은 UI를 만들기 위해서는 2개 이상의 Activity를 나란히 보여주는 것 입니다
Fragments를 사용하면 FragmentManager를 통해서 편하게 처리 할 수있습니다 Activity Lifecycle Event 처리, UI 개별 요소의 상태 관리, 앱 상태를 유지하기 위해서 언제 어느 스크린 요소가 출력되어있는지에 대한 정보 등등을 처리해 줍니다
단일 Activity에서 Fragment를 바꿔줌으로써 여러 화면을 표시하는것과 같은 효과를 줄 수도 있습니다
하지만 이런 구조는 비추천합니다
단일 Activity로 구성할 경우 코드가 복잡합니다
Intent Filter 생성 및 관리가 훨씬 어렵습니다
Activity 코드를 읽고 테스트하고 관리하는데도 어렵습니다
독립적인 구성요소들 함께 묶게 되는 문제가 있습니다 (coupling)
단일 Activity에 민감한 정보와 공유 가능한 일반 정보가 같이 있는 경우 보안의 위험성도 커집니다
Context가 변경될 때 마다 Activity를 만드는 방법을 추천합니다 View에서 Input으로 전환하는등 출력 데이터의 종류가 바뀔 때 하는 것이 좋습니다
Fragment가 UI에서 각각의 독립된 생명 주기를 가진 Mini Activity로 취급한다면 실제 Activity의 Lifecycle와 비교했을 때 어떨까요?
기본적인 Lifecycle은 부모 액티비티와 유사하고 시작, 재개, 일시정지, 정지의 주기를 순환하며 그와 같은 Lifecycle은 프래그먼트 자체 내에서 일어납니다
대부분의 경우 액티비티 생명주기 핸들에 넣을 수 잇는 것은 어떤것이라도 그에 해당하는 프래그먼트 핸들에 넣을 수 있습니다 물론 예외가 몇가지 있긴 합니다
UI를 **onCreate()***에 만드는 대신에 **onCreateView()**를 이용합니다 **onCreateView()**에서는 UI를 생성하거나 inflate해서 데이터 소스와 연결해서 부모 Activity한테 돌려주고 뷰 구조로 통합 될 수 있습니다
onDestoryView() 는 Fragment가 backstack에 추가되기 직전에 호출되는데 부모 Activity와는 독립적입니다
FragmentManager는 FragmentTransaction 이용하여 fragment를 backstack으로 추가, 제거, 대체 할 수 있습니다 하나의 부모 Activity 활성화로 말입니다 그래서 Fragment는 host Activity에 상관없이 Lifecycle을 자유롭게 이동할 수 있습니다
**onDestoryView()**에서 UI와 특별하게 관련된 리소스, Data Cursors, 메모리에 있는 비트맵 같은 것들을 지울 수 있습니다 Fragment가 보이지 않을 때 필요없는 데이터로 앱의 메모리를 사용하지 않도록 합니다
Fragment가 backstack에서 돌아오자마자 **onCreateView()**가 호출이 되고 UI를 다시 만들어서 FragmentTransaction이 나머지 Lifecycle을 통하여 다시 활성화되기 전에 데이터 소스와 다시 연결할 수 있습니다
Fragment는 Activity안에서만 존재할 수 있기 때문에 부모 Activity와 붙여있는지 떨어졌는지 알려주는 onAttach() , onDetach() 호출이 필요합니다 **onAttach()**는 부모 Activity에 대한 참조를 얻을 수 있습니다 **onDetach()**는 Fragment가 소멸 된 뒤에 가장 나중에 일어납니다
onActivityCreated() 부모 Activity가 create가 완료한것을 Fragment에 통지하고 UI와 안전하게 통신 할 수 있는지 나타냅니다
Fragment를 이용하는 마지막 이점은 UI와 관계가 없습니다 알다시피 시각적인 구성요소로서 기기의 설정이 변하면 액티비티는 파괴되고 다시 생성됩니다 하지만 우리가 Fragment를 사용해서 Visual Activity module을 분할하고 로직을 찾는다면 정확하게 그 일을 할 수 있습니다 왜냐하면 이 Fragment은 시각적이지 않기 때문에 UI가 업데이트 될 때마다 새로 만들 필요가 없습니다 onCreate()에서 setRetainInstance(true)를 호출해서 onCreateView()에서 null 을 반환합시다
이렇게 하면 부모 Activity와 독립적으로 작업을 할 수 있습니다 Connections, Thread, Task등의 작업을 중단없이 할 수 있습니다
Tablets 다중 화면 지원 - 구성
Tablets 전용 UI 를 구현해 봅시다
values-w820dp 폴더를 삭제합시다 사용할 이유가 없습니다
layout-sw600dp 폴더를 만듭니다 / 각 파일들을 추가합니다
content_main.xml 수정
android:name=”com.study.sunshine.ForecastFragment”
android:id=”@+id/fragment_forecast”
activity_detail.xml 수정
android:id=”@+id/weather_detail_container”
DetailActivity.java 수정
.add(R.id.weather_detail_container, new DetailFragment())
MainActivity.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Override protected void onCreate (Bundle savedInstanceState) { super .onCreate(savedInstanceState); setContentView(R.layout.activity_main); if (findViewById(R.id.weather_detail_container) != null ) { mTwoPane = true ; if (savedInstanceState == null ) { getSupportFragmentManager().beginTransaction() .replace(R.id.weather_detail_container, new DetailFragment(), DETAILFRAGMENT_TAG) .commit(); } } else { mTwoPane = false ; } mLocation = Utility.getPreferredLocation(this ); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Override protected void onResume () { super .onResume(); String location = Utility.getPreferredLocation(this ); if (location != null && !location.equals(mLocation)) { ForecastFragment forecastFragment = (ForecastFragment) getSupportFragmentManager().findFragmentById(R.id.fragment_forecast); if (forecastFragment != null ) { Log.i(LOG_TAG, "Location Change" ); forecastFragment.onLocationChanged(); } mLocation = location; } }
DetailFragment.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Override public Loader<Cursor> onCreateLoader (int id, Bundle args) { Log.v(LOG_TAG, "In onCreateLoader" ); Intent intent = getActivity().getIntent(); if (intent == null || intent.getData() == null ) { return null ; } return new CursorLoader( getActivity(), intent.getData(), DETAIL_COLUMNS, null , null , null ); }
Nexus 10 으로 가상머신을 돌려보면 처음에 아무것도 안나와서 당황하지만 날씨 업데이트를 요청하면 정상적으로 보이는 모습을 볼 수 있습니다
List Item Click에 대한 액션 처리를 해야합니다 항목을 클릭하면 DetailActivity가 나오는데 이것은 Tablets에 맞지 않습니다
하나의 항목을 클릭하면 DetailFragment가 교체 되는 형식으로 해야합니다
ForecastFragment와 DetailFragment의 데이터를 주고 받는 행위는 MainActivity에서 이루어져야 합니다 단순히 두 Fragment간의 통신을 하게 하면 안됩니다
그렇게 하기 위해서는 ForecastFragment에 Notify callback을 만들 필요가 있습니다
연습
ForecastFragment에 Notify callback을 추가하세요 / Notify callback을 완성하세요
실행하는 환경에 따라서 DetailActivity or DetailFragment 선택을 하세요
DetailFragment에서 setArguments()를 사용해야합니다
정답 ForecastFragment.java
1 2 3 4 5 6 7 8 9 10 11 12 public interface Callback { public void onItemSelected (Uri dateUri) ; } @Override public View onCreateView (LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { ((Callback) getActivity()) .onItemSelected(WeatherContract.WeatherEntry.buildWeatherLocationWithDate( locationSetting, cursor.getLong(COL_WEATHER_DATE))); }
MainActivity.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public class MainActivity extends AppCompatActivity implements ForecastFragment .Callback { @Override protected void onResume () { DetailFragment detailFragment = (DetailFragment)getSupportFragmentManager().findFragmentByTag(DETAILFRAGMENT_TAG); if (detailFragment != null ) { detailFragment.onLocationChanged(location); } } @Override public void onItemSelected (Uri contentUri) { if (mTwoPane) { Bundle args = new Bundle(); args.putParcelable(DetailFragment.DETAIL_URI, contentUri); DetailFragment fragment = new DetailFragment(); fragment.setArguments(args); getSupportFragmentManager().beginTransaction() .replace(R.id.weather_detail_container, fragment, DETAILFRAGMENT_TAG) .commit(); } else { Intent intent = new Intent(this , DetailActivity.class) .setData(contentUri); startActivity(intent); } } }
DetailFragment.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 static final String DETAIL_URI = "URI" ;private Uri mUri;@Override public View onCreateView (LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { Bundle arguments = getArguments(); if (arguments != null ) { mUri = arguments.getParcelable(DetailFragment.DETAIL_URI); } } void onLocationChanged (String newLocation) { Uri uri = mUri; if (null != uri) { long date = WeatherContract.WeatherEntry.getDateFromUri(uri); Uri updatedUri = WeatherContract.WeatherEntry.buildWeatherLocationWithDate(newLocation, date); mUri = updatedUri; getLoaderManager().restartLoader(DETAIL_LOADER, null , this ); } } @Override public Loader<Cursor> onCreateLoader (int id, Bundle args) { if (mUri != null ) { return new CursorLoader( getActivity(), mUri, DETAIL_COLUMNS, null , null , null ); } return null ; }
DetailActivity.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Override protected void onCreate (Bundle savedInstanceState) { super .onCreate(savedInstanceState); setContentView(R.layout.activity_detail); if (savedInstanceState == null ) { Bundle arguments = new Bundle(); arguments.putParcelable(DetailFragment.DETAIL_URI, getIntent().getData()); DetailFragment fragment = new DetailFragment(); fragment.setArguments(arguments); getSupportFragmentManager().beginTransaction() .add(R.id.weather_detail_container, fragment) .commit(); } }
SettingsActivity.java
1 2 3 4 5 @TargetApi(Build.VERSION_CODES.JELLY_BEAN) @Override public Intent getParentActivityIntent () { return super .getParentActivityIntent().addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); }
선택한 항목의 URI를 전달하는 문제에 대해서 어려움이 많을것이라 예상이 됩니다
우리가 사용할 수 있는건 Bundle이 있습니다 Bundle의 데이터는 Key-value 구조 입니다
Bundle과 Fragment의 arguments를 이용하면 쉽게 전달할 수 있습니다
Tablets의 와이어 프레임에서 항목을 선택하면 활성화되는 부분은 활성화 표시가 되어있습니다
항목의 배경 상태를 drawable로 설정하여서 볼 수 있습니다 StateListDrawable는 View의 상태에 따라 drawable을 정할 수 있습니다
적용을 해봅시다
drawable-v21/touch_selector.xml
1 2 3 4 5 6 7 8 9 10 11 <selector xmlns:android ="http://schemas.android.com/apk/res/android" > <item android:state_pressed ="true" > <ripple android:color ="@color/grey" /> </item > <item android:state_activated ="true" android:drawable ="@color/sunshine_light_blue" /> <item android:drawable ="@android:color/transparent" /> </selector >
drawable/touch_selector.xml
1 2 3 4 5 6 7 8 9 10 <selector xmlns:android ="http://schemas.android.com/apk/res/android" > <item android:state_pressed ="true" android:drawable ="@color/sunshine_light_blue" /> <item android:state_activated ="true" android:drawable ="@color/sunshine_light_blue" /> <item android:drawable ="@android:color/transparent" /> </selector >
values/styles.xml
1 2 <style name ="ForecastListStyle" > </style >
values-sw600dp/styles.xml
1 2 3 4 5 6 7 8 9 10 <style name ="ForecastListStyle" > <item name ="android:choiceMode" > singleChoice</item > </style > ``` **color.xml** ```xml <color name ="grey" > #cccccc</color > <color name ="sunshine_light_blue" > #ff64c2f4</color >
list_item_forecast.xml, list_item_forecast_today.xml
1 android:background="@drawable/touch_selector"
fragment_main.xml
1 style="@style/ForecastListStyle"
휴대폰에서 동작 시켰을 때는 보기 어렵지만 Tablets으로 진행한다면 바로 볼 수가 있습니다
생각해보니 우리앱에 큰 문제가 있습니다 마지막 항목을 클릭하고 화면을 회전하면 스크롤이 위로 올라가 있습니다 상당히 불편합니다 이것을 수정해 봅시다
ForecastFragment.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 private ListView mListView;private int mPosition = ListView.INVALID_POSITION;private static final String SELECTED_KEY = "selected_position" ;@Override public View onCreateView (LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { mListView = (ListView) rootView.findViewById(R.id.listview_forecast); mListView.setAdapter(mForecastAdapter); mListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { mPosition = position; } }); if (savedInstanceState != null && savedInstanceState.containsKey(SELECTED_KEY)) { mPosition = savedInstanceState.getInt(SELECTED_KEY); } return rootView; } @Override public void onSaveInstanceState (Bundle outState) { if (mPosition != ListView.INVALID_POSITION) { outState.putInt(SELECTED_KEY, mPosition); } super .onSaveInstanceState(outState); } @Override public void onLoadFinished (Loader<Cursor> loader, Cursor data) { mForecastAdapter.swapCursor(data); if (mPosition != ListView.INVALID_POSITION) { mListView.smoothScrollToPosition(mPosition); } }
스크롤의 위치를 화면이 바뀌기 전 상태와 동일하게 동작을 합니다!
그래도 아직 UI 와이어프레임을 끝낸것은 아닙니다
Tablets상의 화면이랑 핸드폰 상태의 화면이랑 동일합니다 공간이 낭비되는거 같고 불필요 합니다
이것을 수정해 주도록 합시다
문제
다음과 같은 화면을 구성하기 위해서는 fragment_detail.xml 파일을 어디서 수정해야 할까요?
layout
layout-land
layout-sw600dp
layout-sw720dp
정답 layout, layout-land, layout-sw600dp 입니다
Wide하게 화면을 배치하기 위해서는 land, sw600dp 두곳에 파일을 복사해서 사용해야 합니다 하지만 다중 카피를 막기 위해서 layout aliasing을 사용합니다
레이아웃 별칭 사용
이 파일을 이용해서 화면을 표시해 봅시다
values-land/refs.xml, values-sw600dp/refs.xml
1 2 3 4 <?xml version="1.0" encoding="utf-8"?> <resources > <item name ="fragment_detail" type ="layout" > @layout/fragment_detail_wide</item > </resources >
핸드폰에서 화면을 회전하거나 Tablets에서 표시되는 내용이 좀 더 보기 좋게 변하였습니다
우리는 이제 변화를 주고 싶습니다 폰에서는 Today가 보여주는 View를 좀 더 크게 바꾸고 싶습니다 Tablets에서는 모든 view가 동일하게 표현되도록 바꾸고 싶습니다 Tablets은 바로 옆에 Detail화면이 보이는 데 Today를 강조 할 필요가 없습니다
연습
ForecastFragment.getItemViewType()을 수정하세요
ForecastAdapter와 ForecastFragment를 요구 사항대로 수정하세요
ForecastAdapter.java
1 2 3 4 5 6 7 8 9 10 private boolean mUseTodayLayout = true ;public void setUseTodayLayout (boolean useTodayLayout) { mUseTodayLayout = useTodayLayout; } @Override public int getItemViewType (int position) { return (position == 0 && mUseTodayLayout) ? VIEW_TYPE_TODAY : VIEW_TYPE_FUTURE_DAY; }
정답 ForecastFragment.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 private boolean mUseTodayLayout;@Override public View onCreateView (LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { mForecastAdapter.setUseTodayLayout(mUseTodayLayout); return rootView; } public void setUseTodayLayout (boolean useTodayLayout) { mUseTodayLayout = useTodayLayout; if (mForecastAdapter != null ) { mForecastAdapter.setUseTodayLayout(mUseTodayLayout); } }
MainActivity.java
1 2 3 4 5 6 7 @Override protected void onCreate (Bundle savedInstanceState) { ForecastFragment forecastFragment = ((ForecastFragment) getSupportFragmentManager() .findFragmentById(R.id.fragment_forecast)); forecastFragment.setUseTodayLayout(!mTwoPane); }
Visual Mocks 와이어 프레임을 다 완성 시켰습니다
이제 스타일과 뷰를 수정해서 앱을 멋지게 꾸며봅시다!!
앱의 완성 단계를 보여주는 모형들이 있습니다
디자이너들과 작업을 하게 된다면 빨간선으로 크기, 폰트, 색, 간격등을 명시해 줍니다 빨간선은 모형과 동일한 아주 정확한 레이아웃을 만드는 데 도움을 줍니다
Android Design Guide: Metrics and Grids 링크를 참조해서 우리 앱에서 필요한 부분에 맞게 조정하도록 합시다
ActionBar 이제까지는 각자의 View에 style을 적용하는 방법을 설명했었습니다
Activity나 Application에서 모든 View에서 style을 적용하려면 Androidmanifest에서 or 에서 android:theme 속성을 지정해야 합니다
App bar - developer App bar - designer
MainActivity와 DetailActivity의 ActionBar을 봅시다 SettingsActivity은 별도 입니다
이렇게 표시 하기 위해서는 2개의 theme가 필요합니다
DetailActivity Theme : AppTheme
MainActivity Theme : ForecastTheme
화면의 컬러는 다음과 같이 지정되어있습니다
materialpalette Material Design, Daily – MaterialUp
위에 싸이트를 이용하면 색상을 선택하기 편합니다
AppTheme를 Custom 하려면 values - styles.xml 로 이동합니다
1 2 3 4 <style name ="AppTheme" parent ="Theme.AppCompat.Light.DarkActionBar" > <item name ="colorPrimary" > @color/sunshine_blue</item > <item name ="colorPrimaryDark" > @color/sunshine_dark_blue</item > </style >
colors.xml 컬러를 추가합니다
1 2 <color name ="sunshine_blue" > #ff1ca8f4</color > <color name ="sunshine_dark_blue" > #0288D1</color >
MainActivity에서 적용할 테마 ForecastTheme를 만듭니다
style.xml
1 2 3 4 5 6 7 8 <style name ="ForecastTheme" parent ="AppTheme" > <item name ="actionBarStyle" > @style/ActionBar.Solid.Sunshine.NoTitle</item > </style > <style name ="ActionBar.Solid.Sunshine.NoTitle" parent ="@style/Widget.AppCompat.Light.ActionBar.Solid.Inverse" > <item name ="displayOptions" > useLogo|showHome</item > <item name ="logo" > @drawable/ic_logo</item > </style >
Androidmanifest.xml에서 Theme를 변경합니다
1 2 3 4 <activity android:name =".MainActivity" android:label ="@string/app_name" android:theme ="@style/ForecastTheme" >
1 getSupportActionBar().setElevation(0f );
SettingsActivity 화면도 Theme를 설정합시다
styles.xml
1 2 3 <style name ="SettingsTheme" parent ="AppTheme" > </style >
values-v14/styles.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 <?xml version="1.0" encoding="utf-8"?> <resources > <style name ="SettingsTheme" parent ="@android:style/Theme.Holo.Light.DarkActionBar" > <item name ="android:actionBarStyle" > @style/ActionBar.V14.Sunshine.NoTitle</item > </style > <style name ="ActionBar.V14.Sunshine.NoTitle" parent ="@android:style/Widget.Holo.Light.ActionBar.Solid.Inverse" > <item name ="android:background" > @color/sunshine_blue</item > <item name ="android:height" > 56dp</item > </style > </resources >
values-v21/styles.xml
1 2 3 4 <style name ="SettingsTheme" parent ="@android:style/Theme.Material.Light.DarkActionBar" > <item name ="android:colorPrimary" > @color/sunshine_blue</item > <item name ="android:colorPrimaryDark" > @color/sunshine_dark_blue</item > </style >
Androidmanifest.xml
1 2 3 4 5 <activity android:name =".SettingsActivity" android:label ="@string/title_activity_settings" android:parentActivityName =".MainActivity" android:theme ="@style/SettingsTheme" >
이렇게 설정을 하면 SettingsActivity에도 ActionBar 설정이 끝났습니다