Android Design Principles
새로운 테마
복잡한 뷰를 위한 새로운 위젯
사용자 지정 그림자 및 애니메이션을 위한 새로운 API
보기에도 좋고 그만큼 기능적인 면에서도 우수하며 사용하기 쉬운 앱을 만드는것이다!
사용자는 우리 앱을 30초면 평가를 한다 / 이것은 대부분 시각적인 요소(UI)에 편중되어 있다
세련되고 깔끔한가?
전문적으로 보이는가?
사용하기는 얼마나 쉬운가?
디자인이라는건 개발자가 설명하기 어려운 사항이기도 합니다 하지만 우리는 기본적으로 많이 사용되는 어플리케이션, 내가 자주 사용하는 어플리케이션을 생각해 봅시다
자세한 것은 Google 에서 제시하는 디자인 가이드 라인을 참고해 봅시다
Google Design Material design 개발자를 위한 머티리얼 디자인
View & ViewGroup Android UI를 만들기 위해서는 View를 사용합니다 View는 단순하게 직사각형 화면입니다
drawing과 event handling을 처리합니다Material Design - Components - Buttons
다음 같은 Layouts(ViewGroup) 또한 View 입니다 또한 ViewGroup안에는 View와 ViewGroup 다 포함하는 구조가 가능합니다
View의 width와 height에 따라서 뷰의 크기가 어떻게 변하는지 알수있습니다
gravity = center 로 설정하면 글자가 가운대 정렬 하는 모습을 볼 수 있습니다layout_gravity = center 로 설정을 하면 뷰가 부모의 정 가운데에 위치하게 됩니다
padding과 layout_margin 을 이용해서 뷰를 원하는 곳으로 이동 할 수 있습니다
android:visibility=
visible : 보여준다
invisible : 안보여준다(공간 차지 O)
gone : 안보여준다(공간 차지 x)
앱이 실행되는 동안에 visibility 를 변경할 수 있습니다
View - visibility
우리는 앱을 만들기 위해서 다양한 화면 세팅을 생각하고 Wire Frame부터 생성합니다
최종적으로 컬러, 아이콘, 폰트, 크기와 같은 완벽한 레이아웃에 도달하도록 하는것입니다
연습
다음과 같이 화면의 와이어 프레임을 정하고 항목의 아이템을 설정합니다
정답 list_item_forecast.xml
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 45 46 47 48 49 50 51 52 53 54 55 56 <?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:gravity ="center_vertical" android:minHeight ="?android:attr/listPreferredItemHeight" android:orientation ="horizontal" android:padding ="16dp" > <ImageView android:id ="@+id/list_item_icon" android:layout_width ="wrap_content" android:layout_height ="wrap_content" android:src ="@mipmap/ic_launcher" /> <LinearLayout android:layout_width ="0dp" android:layout_height ="wrap_content" android:layout_weight ="1" android:orientation ="vertical" android:paddingLeft ="16dp" > <TextView android:id ="@+id/list_item_date_textview" android:layout_width ="wrap_content" android:layout_height ="wrap_content" android:text ="Tomorrow" /> <TextView android:id ="@+id/list_item_forecast_textview" android:layout_width ="wrap_content" android:layout_height ="wrap_content" android:text ="Clear" /> </LinearLayout > <LinearLayout android:layout_width ="wrap_content" android:layout_height ="wrap_content" android:orientation ="vertical" > <TextView android:id ="@+id/list_item_high_textview" android:layout_width ="wrap_content" android:layout_height ="wrap_content" android:text ="81" /> <TextView android:id ="@+id/list_item_low_textview" android:layout_width ="wrap_content" android:layout_height ="wrap_content" android:text ="68" /> </LinearLayout > </LinearLayout >
연습
새로운 list_item_forecast_today.xml 파일을 만들고 다음과 같은 디자인을 완성해 봅시다
정답 list_item_forecast_today.xml
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 45 46 47 48 49 50 <?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 ="wrap_content" android:gravity ="center_vertical" android:minHeight ="?android:attr/listPreferredItemHeight" android:orientation ="horizontal" android:padding ="16dp" > <LinearLayout android:layout_width ="0dp" android:layout_height ="wrap_content" android:layout_weight ="1" android:gravity ="center_horizontal" android:orientation ="vertical" > <TextView android:id ="@+id/list_item_date_textview" android:layout_width ="wrap_content" android:layout_height ="wrap_content" /> <TextView android:id ="@+id/list_item_high_textview" android:layout_width ="wrap_content" android:layout_height ="wrap_content" /> <TextView android:id ="@+id/list_item_low_textview" android:layout_width ="wrap_content" android:layout_height ="wrap_content" /> </LinearLayout > <LinearLayout android:layout_width ="0dp" android:layout_height ="wrap_content" android:layout_weight ="1" android:gravity ="center_horizontal" android:orientation ="vertical" > <ImageView android:id ="@+id/list_item_icon" android:layout_width ="wrap_content" android:layout_height ="wrap_content" /> <TextView android:id ="@+id/list_item_forecast_textview" android:layout_width ="wrap_content" android:layout_height ="wrap_content" /> </LinearLayout > </LinearLayout >
ForecastAdapter.bindView() 메서드 안에 내용은 주석합니다 실행을 하면 단순한 화면만 반복되어서 나옵니다 아직 Today 에 대한 표시는 안했습니다
다음과 같이 Cursor의 데이터를 View에 Binding 합시다
ForecastAdapter는 CursorAdapter를 상속해서 구현했습니다 CursorAdapter는 abstract class 입니다. 구현해야 할 메서드가 2개면 충분합니다 newView(), bindView()
newView()는 데이터가 들어 있지 않은 새 항목의 레이아웃을 만듭니다 bindView()는 기존의 만들어져있는 레이아웃을 이용하여서 Cursor 데이터를 사용하여 업데이트합니다
연습
ForecastAdapter.bindView() 메서드를 완성해 봅시다
list_item_forecast.xml 하드 코딩으로 되어있는 내용들 정리 합시다
밑에 2개의 파일 내용을 추가한 뒤에 해보세요
strings.xml
1 2 3 4 5 6 <resources xmlns:xliff ="http://schemas.android.com/apk/res-auto" > <string name ="today" > 오늘</string > <string name ="tomorrow" > 내일</string > <string name ="format_full_friendly_date" > <xliff:g id ="day" > %1$s</xliff:g > , <xliff:g id ="date" > %2$s</xliff:g > </string > </resources >
Utility.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 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 public static final String DATE_FORMAT = "yyyyMMdd" ;public static String getFriendlyDayString (Context context, long dateInMillis) { Time time = new Time(); time.setToNow(); long currentTime = System.currentTimeMillis(); int julianDay = Time.getJulianDay(dateInMillis, time.gmtoff); int currentJulianDay = Time.getJulianDay(currentTime, time.gmtoff); if (julianDay == currentJulianDay) { String today = context.getString(R.string.today); int formatId = R.string.format_full_friendly_date; return String.format( context.getString(formatId, today, getFormattedMonthDay(context, dateInMillis))); } else if ( julianDay < currentJulianDay + 7 ) { return getDayName(context, dateInMillis); } else { SimpleDateFormat shortenedDateFormat = new SimpleDateFormat("MMM dd EEE" ); return shortenedDateFormat.format(dateInMillis); } } public static String getDayName (Context context, long dateInMillis) { Time t = new Time(); t.setToNow(); int julianDay = Time.getJulianDay(dateInMillis, t.gmtoff); int currentJulianDay = Time.getJulianDay(System.currentTimeMillis(), t.gmtoff); if (julianDay == currentJulianDay) { return context.getString(R.string.today); } else if ( julianDay == currentJulianDay +1 ) { return context.getString(R.string.tomorrow); } else { Time time = new Time(); time.setToNow(); SimpleDateFormat dayFormat = new SimpleDateFormat("EEEE" ); return dayFormat.format(dateInMillis); } } public static String getFormattedMonthDay (Context context, long dateInMillis ) { Time time = new Time(); time.setToNow(); SimpleDateFormat dbDateFormat = new SimpleDateFormat(Utility.DATE_FORMAT); SimpleDateFormat monthDayFormat = new SimpleDateFormat("MMMM dd" ); String monthDayString = monthDayFormat.format(dateInMillis); return monthDayString; }
정답 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 @Override public void bindView (View view, Context context, Cursor cursor) { int weatherId = cursor.getInt(ForecastFragment.COL_WEATHER_ID); ImageView iconView = (ImageView) view.findViewById(R.id.list_item_icon); iconView.setImageResource(R.mipmap.ic_launcher); long dateInMillis = cursor.getLong(ForecastFragment.COL_WEATHER_DATE); TextView dateView = (TextView) view.findViewById(R.id.list_item_date_textview); dateView.setText(Utility.getFriendlyDayString(context, dateInMillis)); String description = cursor.getString(ForecastFragment.COL_WEATHER_DESC); TextView descriptionView = (TextView) view.findViewById(R.id.list_item_forecast_textview); descriptionView.setText(description); boolean isMetric = Utility.isMetric(context); double high = cursor.getDouble(ForecastFragment.COL_WEATHER_MAX_TEMP); TextView highView = (TextView) view.findViewById(R.id.list_item_high_textview); highView.setText(Utility.formatTemperature(high, isMetric)); double low = cursor.getDouble(ForecastFragment.COL_WEATHER_MIN_TEMP); TextView lowView = (TextView) view.findViewById(R.id.list_item_low_textview); lowView.setText(Utility.formatTemperature(low, isMetric)); }
지금은 모든 항목들이 동일한 레이아웃입니다 Today(오늘) 날씨는 레이아웃이 다르게 표시 되어야 합니다
getItemViewType() 이것을 이용해서 다르게 표시 할 수있습니다 getViewTypeCount() 은 총 몇가지 뷰 형태의 레이아웃이 있는지 표시합니다
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 private static final int VIEW_TYPE_TODAY = 0 ;private static final int VIEW_TYPE_FUTURE_DAY = 1 ;private static final int VIEW_TYPE_COUNT = 2 ;@Override public int getItemViewType (int position) { return position == 0 ? VIEW_TYPE_TODAY : VIEW_TYPE_FUTURE_DAY; } @Override public int getViewTypeCount () { return VIEW_TYPE_COUNT; } @Override public View newView (Context context, Cursor cursor, ViewGroup parent) { int viewType = getItemViewType(cursor.getPosition()); int layoutId = -1 ; return LayoutInflater.from(context).inflate(layoutId, parent, false ); }
연습
정답 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Override public View newView (Context context, Cursor cursor, ViewGroup parent) { int viewType = getItemViewType(cursor.getPosition()); int layoutId = -1 ; switch (viewType) { case VIEW_TYPE_TODAY: layoutId = R.layout.list_item_forecast_today; break ; case VIEW_TYPE_FUTURE_DAY: layoutId = R.layout.list_item_forecast; break ; } return LayoutInflater.from(context).inflate(layoutId, parent, false ); }
bindView() 메서드는 모든 다른 View에 데이터를 설정합니다(Data Binding) 이미 사용 된 적이 있는 View이었다고해도 전체 View에서 다시 한번 찾아서 사용하게 됩니다
매번 findViewById 호출을 제거하기 위해서 ViewHolder를 이용합니다
하나의 레이아웃안에 여러개의 View를 사용하는 경우 레이아웃에서 각 View를 참조하는 멤버 변수를 포함한 ViewHolder를 이용합니다
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public static class ViewHolder { public final ImageView iconView; public final TextView dateView; public final TextView descriptionView; public final TextView highTempView; public final TextView lowTempView; public ViewHolder (View view) { iconView = (ImageView) view.findViewById(R.id.list_item_icon); dateView = (TextView) view.findViewById(R.id.list_item_date_textview); descriptionView = (TextView) view.findViewById(R.id.list_item_forecast_textview); highTempView = (TextView) view.findViewById(R.id.list_item_high_textview); lowTempView = (TextView) view.findViewById(R.id.list_item_low_textview); } }
1 2 3 4 5 6 7 8 @Override public View newView (Context context, Cursor cursor, ViewGroup parent) { View view = LayoutInflater.from(context).inflate(layoutId, parent, false ); ViewHolder viewHolder = new ViewHolder(view); view.setTag(viewHolder); return view; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Override public void bindView (View view, Context context, Cursor cursor) { ViewHolder viewHolder = (ViewHolder) view.getTag(); viewHolder.iconView.setImageResource(R.mipmap.ic_launcher); long dateInMillis = cursor.getLong(ForecastFragment.COL_WEATHER_DATE); viewHolder.dateView.setText(Utility.getFriendlyDayString(context, dateInMillis)); String description = cursor.getString(ForecastFragment.COL_WEATHER_DESC); viewHolder.descriptionView.setText(description); boolean isMetric = Utility.isMetric(context); double high = cursor.getDouble(ForecastFragment.COL_WEATHER_MAX_TEMP); viewHolder.highTempView.setText(Utility.formatTemperature(high, isMetric)); double low = cursor.getDouble(ForecastFragment.COL_WEATHER_MIN_TEMP); viewHolder.lowTempView.setText(Utility.formatTemperature(low, isMetric)); }
실행하면 잘 나오지만 먼가 허전합니다 날씨를 표현할때 표기법에 맞게 표현해주면 더 완벽할꺼같습니다
가장 무난하게 하는 방법은 번역자가 각 언어에 맞게 텍스트 및 매개변수를 재배치할 수 있도록 하는 것입니다 참고 : xliff tag
1 <string name ="format_temperature" > <xliff:g id ="temp" > %1.0f</xliff:g > \u00B0</string >
1 return context.getString(R.string.format_temperature, temp);
-7° 같은 표기가 완성 됩니다
MainActivity는 어느정도 했으니 DetailActivity를 해봅시다
날짜, 최고/최저기온, 추가적인 기상정보, 날씨 아이콘, 날씨 예보 등이 되어있습니다
연습 DetailActivity 를 구성해봅시다
fragment_detail.xml 수정 (스크롤 추가)
DetailActivity와 DetailFragment의 분리
DetailFragment View 수정
strings.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <string name ="format_wind_mph" > Wind: <xliff:g id ="speed" > %1$1.0f</xliff:g > mph <xliff:g id ="direction" > %2$s</xliff:g > </string > <string name ="format_wind_kmh" > Wind: <xliff:g id ="speed" > %1$1.0f</xliff:g > km/h <xliff:g id ="direction" > %2$s</xliff:g > </string > <string name ="format_pressure" > Pressure: <xliff:g id ="pressure" > %1.0f</xliff:g > hPa</string > <string name ="format_humidity" > Humidity: <xliff:g id ="humidity" > %1.0f</xliff:g > %%</string >
Utility.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 public static String getFormattedWind (Context context, float windSpeed, float degrees) { int windFormat; if (Utility.isMetric(context)) { windFormat = R.string.format_wind_kmh; } else { windFormat = R.string.format_wind_mph; windSpeed = 0.621371192237334f * windSpeed; } String direction = "Unknown" ; if (degrees >= 337.5 || degrees < 22.5 ) { direction = "N" ; } else if (degrees >= 22.5 && degrees < 67.5 ) { direction = "NE" ; } else if (degrees >= 67.5 && degrees < 112.5 ) { direction = "E" ; } else if (degrees >= 112.5 && degrees < 157.5 ) { direction = "SE" ; } else if (degrees >= 157.5 && degrees < 202.5 ) { direction = "S" ; } else if (degrees >= 202.5 && degrees < 247.5 ) { direction = "SW" ; } else if (degrees >= 247.5 && degrees < 292.5 ) { direction = "W" ; } else if (degrees >= 292.5 || degrees < 22.5 ) { direction = "NW" ; } return String.format(context.getString(windFormat), windSpeed, direction); }
정답
onLoadFinished()에서 매번 findViewById를 호출하기 때문에 불필요한 행동을 최소화 하기 위해서 변수로 설정합니다
Optimizing Layouts Nested Layouts을 구성하는 법도 배웠습니다 하지만 복잡한 Layouts을 inflate하면 리소스도 많이 잡아먹고 앱의 성능과 반응성에도 문제가 생길 수 있습니다
다음과 같은 규칙을 염두해 둡시다
좁고 깊은 레이아웃 구조보다는 넓고 얕은 레이아웃 구조가 좋습니다
같은 레벨의 항목을 여러 개 두고 하위 레벨의 항목을 적게 유지하는 것입니다
액티비티 전체 구조에서 Nested View를 10개 이하로 유지합니다
총 80개 이하의 뷰를 사용합니다
Hierarchy Viewer Optimizing Your UI Improve Your Code with Lint
HierarchyViewer 를 총해서 Activity와 Application을 볼 수가 있습니다
또한 Android Studio 에는 Lint 라는 도구가 있습니다
Lint는 정적 분석 도구 입니다 레이아웃 접근성 문제, 번역 누락, 하드코딩된 문자열등등에 대해서 표시해줍니다
Responsive Design 이제 와이어 프레임 단계의 레이아웃을 두 화면(MainActivity, DetailActivity)에 적용을 하였습니다
미리 보기 화면에서 테블릿으로 변경해서 보면 너비만 넓어지고 별 차이가 없습니다 상세보기 화면을 같이 보여주거나 리스트를 보여주는 형식을 변경하는게 더 좋을꺼 같습니다
이렇게 하는게 반응형 디자인의 일부입니다
반응형 디자인이란 앱을 디자인할 때 다양한 화면 크기에서 사용될 것을 고려하는 것을 말합니다 이런 디자인은 어떻게 하며 태블릿처럼 큰 화면의 기기를 고려해 만든다는 것은 어떤 의미일까요?
단순하게 늘어진 UI로만 구성하는것이 아니라 크기에 맞게 보여줄 정보의 제공을 변경하는것을 의미합니다
태블릿 앱 품질 Planning for Multiple Touchscreen Sizes
실제로 Application을 개발하다보면 tablets을 지원 안하는 경우가 더 많습니다 하지만 앞으로 우리는 설계를 할 때 반응형 디자인으로 설계하기를 바랍니다
Application을 만들어야 하는데 기기의 종류는 너무나도 많습니다
휴대폰, 7인치 테블릿, 10인치 테블릿 등등 너무 많습니다 우리는 DP, DPI로 분류를 하는것이 더 좋습니다
ldpi
mdpi
hdpi
xhdpi
xxhdpi
xxxhdpi
~120
~160
~240
~320
~480
~640
px로 하는 것이 아니라 dp를 이용해서 어떤 화면의 크기이던 같은 크기의 이미지로 보여 줄 수 있다
dp 단위를 픽셀 단위로 변환 다중 화면 지원 - 구성 예시
Android의 리소스들은 모두 res 폴더 안에 있습니다
런타임시 Android가 적절하게 리소스를 선택하게 됩니다
다중화면지원 Devices and Displays
이미지를 다운받고 우리 프로젝트에 추가하면 됩니다
Assert
다운 받은 폴더를 확인하면 drawable-hdpi와 같은 형태로 되어있습니다 하지만 우리가 사용할때는 R.drawable.{파일이름} 같은 형태로 사용하면 화면 크기에 맞게 Android 에서 찾아줍니다
연습
DetailFragment를 수정해서 날씨 아이콘을 표현하세요
Utility.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 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 public static int getIconResourceForWeatherCondition (int weatherId) { if (weatherId >= 200 && weatherId <= 232 ) { return R.drawable.ic_storm; } else if (weatherId >= 300 && weatherId <= 321 ) { return R.drawable.ic_light_rain; } else if (weatherId >= 500 && weatherId <= 504 ) { return R.drawable.ic_rain; } else if (weatherId == 511 ) { return R.drawable.ic_snow; } else if (weatherId >= 520 && weatherId <= 531 ) { return R.drawable.ic_rain; } else if (weatherId >= 600 && weatherId <= 622 ) { return R.drawable.ic_snow; } else if (weatherId >= 701 && weatherId <= 781 ) { return R.drawable.ic_fog; } else if (weatherId == 800 ) { return R.drawable.ic_clear; } else if (weatherId == 801 ) { return R.drawable.ic_light_clouds; } else if (weatherId >= 802 && weatherId <= 804 ) { return R.drawable.ic_cloudy; } return -1 ; } public static int getArtResourceForWeatherCondition (int weatherId) { if (weatherId >= 200 && weatherId <= 232 ) { return R.drawable.art_storm; } else if (weatherId >= 300 && weatherId <= 321 ) { return R.drawable.art_light_rain; } else if (weatherId >= 500 && weatherId <= 504 ) { return R.drawable.art_rain; } else if (weatherId == 511 ) { return R.drawable.art_snow; } else if (weatherId >= 520 && weatherId <= 531 ) { return R.drawable.art_rain; } else if (weatherId >= 600 && weatherId <= 622 ) { return R.drawable.art_snow; } else if (weatherId >= 701 && weatherId <= 781 ) { return R.drawable.art_fog; } else if (weatherId == 800 ) { return R.drawable.art_clear; } else if (weatherId == 801 ) { return R.drawable.art_light_clouds; } else if (weatherId >= 802 && weatherId <= 804 ) { return R.drawable.art_clouds; } return -1 ; }
정답 1 mIconView.setImageResource(Utility.getArtResourceForWeatherCondition(weatherId));
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Override public void bindView (View view, Context context, Cursor cursor) { ViewHolder viewHolder = (ViewHolder) view.getTag(); int viewType = getItemViewType(cursor.getPosition()); switch (viewType) { case VIEW_TYPE_TODAY: { viewHolder.iconView.setImageResource(Utility.getArtResourceForWeatherCondition( cursor.getInt(ForecastFragment.COL_WEATHER_CONDITION_ID))); break ; } case VIEW_TYPE_FUTURE_DAY: { viewHolder.iconView.setImageResource(Utility.getIconResourceForWeatherCondition( cursor.getInt(ForecastFragment.COL_WEATHER_CONDITION_ID))); break ; } } }
Tablets 은 다음과 같이 화면을 설계하게 됩니다
한 화면에 2개의 fragment를 포함합니다 폰에서는 각 각 하나의 화면으로 존재했던 사항들 입니다
Building a Dynamic UI with Fragments
한 화면에서 각각의 Fragment가 통신을 하는 방법을 알아야 합니다 오늘 날씨를 보여주는 부분은 다른 날씨 보여주는 부분과 같은 모양으로 나오게 해야합니다 현재 활성화 되어있는 항목을 표시하는 방법도 필요하게 됩니다