App 引導界面
1、前言
最近在學習實現App的引導界面,本篇文章對設計流程及需要注意的地方做一個淺顯的總結。
附上項目鏈接,供和我水平類似的初學者參考——http://files.cnblogs.com/files/tgyf/Tutorial.rar。
對於有引導界面的App,剛安裝或使用后將其數據清除(Setting-Apps-...),啟動后就會出現引導界面,目的是向用戶介紹本款應用的使用方法或主要功能。
App引導過程的頁面數一般為為3到6個,特殊的如刷機后的SetupWizard設置頁面將近10個。除了非常必要,放過多頁面會影響用戶體驗,雖然可以在界面上添加“跳過”按鈕(最近較為常用的按鈕為“立即體驗”)為不需要被引導的用戶提供進入App的捷徑。
有兩種操作方式讓用戶左/右翻動頁面:點擊按鈕和手勢滑動。前者需要在界面上添加兩個按鈕(一般以左/右箭頭圖標作為顯示內容),而后者直接識別用戶手指在屏幕上的滑動操作,不過兩者最終實現的頁面切換方法是相同的。隨着時間的推移,很多App為了界面的簡潔及美觀而只為用戶提供手勢滑動來翻動頁面,當然還是有一些App仍然同時提供了上述的兩種操作方式。
先給出一張常見的引導界面圖(網絡上找的):
2、判斷是否是第一次啟動
無論之前有沒有這方面的開發經驗,都不難想到:要判斷App是否是第一次啟動,需要從某個地方讀取一個記錄啟動狀態(或者說啟動次數)的變量值,而且這個變量值不能隨着應用的關閉而消失,除非將其數據清除或卸載。將這種類型的數據保存在文件中是不錯的方法,但這里不用File類,因為Android提供了一個非常好用的類——SharedPreferences。
記錄啟動狀態的數據是在App啟動后的類中進行讀寫的(非引導界面相關類),這里是主類MainActivity。直接上代碼:
1 package com.example.tutorial; 2 3 import android.content.Context; 4 import android.content.Intent; 5 import android.content.SharedPreferences; 6 import android.content.SharedPreferences.Editor; 7 import android.os.Bundle; 8 import android.support.v7.app.ActionBarActivity; 9 import android.widget.Toast; 10 11 public class MainActivity extends ActionBarActivity { 12 13 @Override 14 protected void onCreate(Bundle savedInstanceState) { 15 super.onCreate(savedInstanceState); 16 setContentView(R.layout.activity_main); 17 18 SharedPreferences googleActivitySP = getSharedPreferences("Tutorial", Context.MODE_PRIVATE); 19 boolean firstStart = googleActivitySP.getBoolean("first_start", true); 20 if(firstStart == true){ 21 22 Intent intent = new Intent(this, TutorialIntroPageActivity.class); 23 startActivity(intent); 24 25 Toast.makeText(this, "Tutorial first start", Toast.LENGTH_SHORT).show(); 26 Editor edit = googleActivitySP.edit(); 27 edit.putBoolean("first_start", false); 28 edit.commit(); 29 } 30 31 } 32 33 }
如代碼中所示,記錄App啟動狀態的變量為boolean型first_start,約定第一次啟動時其值為true,否則為false。
剛開始這樣使用SharedPreferences類的時候,相信也有人和我一樣會疑惑:如代碼18、19行,一上來就是獲取文件與變量值,原來不存在怎么辦?這就是該類智能的地方,類似File又勝於File,當文件不存在時就創建,當變量不存在時就返回給定的默認值。即:
a、App初次啟動時會在相應目錄中新建一個文件,這里是data/data/com.example.tutorial/Tutorial.xml,私有模式。注意默認是xml格式,文件名稱與模式分別由方法getSharedPreferences()的第一、二參數決定。若想看其是否生成可以通過Eclipse的DDMS,若想看其內容可以通過在CMD下的adb shell命令進入Shell模式,cd定位到目錄后用cat filename查看。
b、初次啟動時start_first變量並不存在,所以返回值為給定的默認值true,方法getBoolean()第一、二參數分別指定了需要獲取的變量名與默認返回值,若變量存在就返回實際值,不存在就返回給定的默認值,不會因為變量不存在而報異常。不過該類還提供了判斷變量是否存在的方法,感興趣的朋友可以自己研究。
第一次啟動App,獲取的start_first變量值為true,所以如代碼22、23行利用Intent類打開引導界面——TutorialIntroPageActivity類實現的Activity(稍候會講解)。接着如代碼26-28行將變量值設置為false,以后運行該App獲取的start_first變量值均為false,就不會打開引導界面了。
實現過后會發現,這些曾不敢觸碰以為會很高深的點也不過如此。所以,要成長就要勇於探索、犯錯、總結。
3、引導界面的實現
常見Activity的差別除了打開次數外,引導界面做的事情簡單,主要是向用戶展示App的使用說明與主要功能,做多加上幾個按鈕。
引導界面Activity在類TutorialIntroPageActivity中進行實現,先給出代碼:
1、布局文件
1 <?xml version="1.0" encoding="utf-8"?> 2 <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" 3 android:layout_width="match_parent" 4 android:layout_height="match_parent" > 5 6 <LinearLayout android:id="@+id/tutorial_layout" 7 android:layout_width="match_parent" 8 android:layout_height="wrap_content" 9 android:layout_centerInParent="true" 10 android:layout_marginLeft="60dp" 11 android:layout_marginRight="60dp" 12 android:gravity="center" 13 android:orientation="horizontal" > 14 15 <ImageView 16 android:id="@+id/image_tutorial" 17 android:layout_width="310dp" 18 android:layout_height="564dp" 19 android:background="@drawable/image1" /> 20 21 </LinearLayout> 22 23 <RelativeLayout 24 android:layout_width="match_parent" 25 android:layout_height="60dp" 26 android:layout_alignParentBottom="true" 27 android:layout_marginLeft="60dp" 28 android:layout_marginRight="60dp" 29 android:layout_marginBottom="20dp" > 30 31 <LinearLayout 32 android:layout_width="wrap_content" 33 android:layout_height="wrap_content" 34 android:layout_centerInParent="true" 35 android:gravity="center" 36 android:orientation="horizontal" > 37 38 <ImageView 39 android:id="@+id/tutorial_indicator1" 40 android:layout_width="wrap_content" 41 android:layout_height="wrap_content" 42 android:background="@drawable/indicator_page" /> 43 44 <ImageView 45 android:id="@+id/tutorial_indicator2" 46 android:layout_width="wrap_content" 47 android:layout_height="wrap_content" 48 android:background="@drawable/indicator_dot" /> 49 50 <ImageView 51 android:id="@+id/tutorial_indicator3" 52 android:layout_width="wrap_content" 53 android:layout_height="wrap_content" 54 android:background="@drawable/indicator_dot" /> 55 56 <ImageView 57 android:id="@+id/tutorial_indicator4" 58 android:layout_width="wrap_content" 59 android:layout_height="wrap_content" 60 android:background="@drawable/indicator_dot" /> 61 62 </LinearLayout> 63 64 <LinearLayout 65 android:layout_width="wrap_content" 66 android:layout_height="60dp" 67 android:layout_alignParentRight="true" 68 android:gravity="center" 69 android:orientation="horizontal" > 70 71 <Button 72 android:id="@+id/skip_button" 73 android:layout_width="wrap_content" 74 android:layout_height="wrap_content" 75 android:text="skip" 76 android:textSize="20dp" 77 android:textColor="#323232" 78 android:background="@android:color/transparent" 79 android:drawableRight="@drawable/skip" 80 android:drawablePadding="10dp" /> 81 82 <Button 83 android:id="@+id/done_button" 84 android:layout_width="wrap_content" 85 android:layout_height="wrap_content" 86 android:text="done" 87 android:textSize="20dp" 88 android:textColor="#323232" 89 android:background="@android:color/transparent" 90 android:drawableRight="@drawable/done" 91 android:drawablePadding="10dp" 92 android:visibility="gone" /> 93 94 </LinearLayout> 95 96 </RelativeLayout> 97 98 </RelativeLayout>
界面上的組件很簡單:
a、中間為一個顯示主要信息的ImageView,頁面切換時只需要改變其顯示的圖片;
b、下方為四個指示點+一個按鈕,四個指示點對應着有四個頁面,按鈕用來結束該引導界面;
注意在文件的最后其實放置了兩個按鈕,當頁面在前三頁時顯示前者——“跳過”(Skip),第四頁時顯示后者——完成(Done),默認將完成按鈕隱藏,切換在Java代碼中隨着頁面的改變而進行。當然,也可以只放置一個按鈕,在Java中另加文本及圖標的改變。
2、Java實現文件
1 package com.example.tutorial; 2 3 import com.example.tutorial.R; 4 5 import android.app.Activity; 6 import android.content.Intent; 7 import android.content.res.Configuration; 8 import android.os.Bundle; 9 import android.view.GestureDetector; 10 import android.view.MotionEvent; 11 import android.view.View; 12 import android.view.GestureDetector.OnGestureListener; 13 import android.view.View.OnClickListener; 14 import android.view.View.OnTouchListener; 15 import android.widget.Button; 16 import android.widget.ImageButton; 17 import android.widget.ImageView; 18 import android.widget.TextView; 19 20 public class TutorialIntroPageActivity extends Activity implements OnTouchListener{ 21 private static final float LIMIT_ANGLE_TAN = 1.5f; 22 23 private ImageView mTutorialImage; 24 25 private ImageView mIndicator1; 26 private ImageView mIndicator2; 27 private ImageView mIndicator3; 28 private ImageView mIndicator4; 29 30 private Button mSkipButton; 31 private Button mDoneButton; 32 33 private GestureDetector mDetector = null; 34 private int mStep = 0; 35 36 @Override 37 protected void onCreate(Bundle savedInstanceState) { 38 super.onCreate(savedInstanceState); 39 40 setContentView(R.layout.tutorial_info_page); 41 42 mIndicator1 = (ImageView)findViewById(R.id.tutorial_indicator1); 43 mIndicator2 = (ImageView)findViewById(R.id.tutorial_indicator2); 44 mIndicator3 = (ImageView)findViewById(R.id.tutorial_indicator3); 45 mIndicator4 = (ImageView)findViewById(R.id.tutorial_indicator4); 46 47 mTutorialImage = (ImageView)findViewById(R.id.image_tutorial); 48 mDetector = new GestureDetector(this, new TutorialImageGesture()); 49 mTutorialImage.setOnTouchListener(this); 50 51 mSkipButton = (Button) findViewById(R.id.skip_button); 52 mSkipButton.setOnClickListener(mOnSkipOrDoneButtonClickListener); 53 mDoneButton = (Button) findViewById(R.id.done_button); 54 mDoneButton.setOnClickListener(mOnSkipOrDoneButtonClickListener); 55 56 if(savedInstanceState != null) 57 { 58 mStep = savedInstanceState.getInt("pageStep"); 59 } 60 61 boolean isLandscape = getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; 62 63 switch(mStep){ 64 case 0: 65 break; 66 case 1: 67 mIndicator1.setBackgroundResource(R.drawable.indicator_dot); 68 mIndicator2.setBackgroundResource(R.drawable.indicator_page); 69 if(isLandscape) 70 mTutorialImage.setBackgroundResource(R.drawable.image2); 71 else 72 mTutorialImage.setBackgroundResource(R.drawable.image2); 73 break; 74 case 2: 75 mIndicator1.setBackgroundResource(R.drawable.indicator_dot); 76 mIndicator3.setBackgroundResource(R.drawable.indicator_page); 77 if(isLandscape) 78 mTutorialImage.setBackgroundResource(R.drawable.image3); 79 else 80 mTutorialImage.setBackgroundResource(R.drawable.image3); 81 break; 82 case 3: 83 mIndicator1.setBackgroundResource(R.drawable.indicator_dot); 84 mIndicator4.setBackgroundResource(R.drawable.indicator_page); 85 if(isLandscape) 86 mTutorialImage.setBackgroundResource(R.drawable.image4); 87 else 88 mTutorialImage.setBackgroundResource(R.drawable.image4); 89 mDoneButton.setVisibility(View.VISIBLE); 90 mSkipButton.setVisibility(View.GONE); 91 break; 92 } 93 } 94 95 @Override 96 protected void onSaveInstanceState(Bundle outState) { 97 super.onSaveInstanceState(outState); 98 outState.putInt("pageStep", mStep); 99 } 100 101 private void showPrePage(){ 102 boolean isLandscape = getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; 103 104 if(mStep == 3){ 105 mIndicator3.setBackgroundResource(R.drawable.indicator_page); 106 mIndicator4.setBackgroundResource(R.drawable.indicator_dot); 107 if(isLandscape) 108 mTutorialImage.setBackgroundResource(R.drawable.image3); 109 else 110 mTutorialImage.setBackgroundResource(R.drawable.image3); 111 mDoneButton.setVisibility(View.GONE); 112 mSkipButton.setVisibility(View.VISIBLE); 113 mStep--; 114 }else if(mStep == 2){ 115 mIndicator2.setBackgroundResource(R.drawable.indicator_page); 116 mIndicator3.setBackgroundResource(R.drawable.indicator_dot); 117 if(isLandscape) 118 mTutorialImage.setBackgroundResource(R.drawable.image2); 119 else 120 mTutorialImage.setBackgroundResource(R.drawable.image2); 121 mStep--; 122 }else if(mStep == 1){ 123 mIndicator1.setBackgroundResource(R.drawable.indicator_page); 124 mIndicator2.setBackgroundResource(R.drawable.indicator_dot); 125 if(isLandscape) 126 mTutorialImage.setBackgroundResource(R.drawable.image1); 127 else 128 mTutorialImage.setBackgroundResource(R.drawable.image1); 129 mStep--; 130 } 131 } 132 133 private void showNextPage(){ 134 boolean isLandscape = getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; 135 136 if(mStep == 0){ 137 mIndicator1.setBackgroundResource(R.drawable.indicator_dot); 138 mIndicator2.setBackgroundResource(R.drawable.indicator_page); 139 if(isLandscape) 140 mTutorialImage.setBackgroundResource(R.drawable.image2); 141 else 142 mTutorialImage.setBackgroundResource(R.drawable.image2); 143 mStep++; 144 }else if(mStep == 1){ 145 mIndicator2.setBackgroundResource(R.drawable.indicator_dot); 146 mIndicator3.setBackgroundResource(R.drawable.indicator_page); 147 if(isLandscape) 148 mTutorialImage.setBackgroundResource(R.drawable.image3); 149 else 150 mTutorialImage.setBackgroundResource(R.drawable.image3); 151 mStep++; 152 }else if(mStep == 2){ 153 mIndicator3.setBackgroundResource(R.drawable.indicator_dot); 154 mIndicator4.setBackgroundResource(R.drawable.indicator_page); 155 if(isLandscape) 156 mTutorialImage.setBackgroundResource(R.drawable.image4); 157 else 158 mTutorialImage.setBackgroundResource(R.drawable.image4); 159 mDoneButton.setVisibility(View.VISIBLE); 160 mSkipButton.setVisibility(View.GONE); 161 mStep++; 162 } 163 } 164 165 private OnClickListener mOnSkipOrDoneButtonClickListener = new OnClickListener() { 166 167 @Override 168 public void onClick(View arg0) { 169 Intent intent = new Intent(TutorialIntroPageActivity.this, MainActivity.class); 170 if(getIntent().getParcelableExtra(Intent.EXTRA_INTENT) != null){ 171 intent.putExtra(Intent.EXTRA_INTENT, getIntent().getParcelableExtra(Intent.EXTRA_INTENT)); 172 } 173 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); 174 startActivity(intent); 175 //finish(); 176 } 177 178 }; 179 180 @Override 181 public boolean onTouchEvent(MotionEvent event) { 182 mDetector.onTouchEvent(event); 183 return true; 184 } 185 186 public class TutorialImageGesture implements OnGestureListener { 187 188 @Override 189 public boolean onDown(MotionEvent arg0) { 190 // TODO Auto-generated method stub 191 return false; 192 } 193 194 @Override 195 public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { 196 float ver = Math.abs(e1.getY() - e2.getY()); 197 float hor = Math.abs(e1.getX() - e2.getX()); 198 if ( ver / hor > LIMIT_ANGLE_TAN || Math.abs(velocityX)<500) { 199 return false; 200 } 201 202 if (e2.getX() - e1.getX() < 0) { 203 showNextPage(); 204 } 205 else { 206 showPrePage(); 207 } 208 return true; 209 } 210 211 @Override 212 public void onLongPress(MotionEvent arg0) { 213 // TODO Auto-generated method stub 214 215 } 216 217 @Override 218 public boolean onScroll(MotionEvent arg0, MotionEvent arg1, float arg2, 219 float arg3) { 220 // TODO Auto-generated method stub 221 return false; 222 } 223 224 @Override 225 public void onShowPress(MotionEvent arg0) { 226 // TODO Auto-generated method stub 227 228 } 229 230 @Override 231 public boolean onSingleTapUp(MotionEvent arg0) { 232 // TODO Auto-generated method stub 233 return false; 234 } 235 236 } 237 238 @Override 239 public boolean onTouch(View arg0, MotionEvent arg1) { 240 // TODO Auto-generated method stub 241 return false; 242 } 243 }
實現過程沒有特別復雜的地方,接下來對幾個地方值得回味的進行講解,以后也許會用到。
a、代碼61、102、134行對設備方向的獲取,因為一般Activity會隨着設備的橫/豎屏切換時而重啟,且兩種狀態下的布局樣式往往是不一樣的,所以需要根據方向來實時調整顯示的界面組件。本例給出的圖像是一樣的,所以看不出差別。
b、showprePage()和showNextPage()除了判斷設備方向以外,主要負責引導頁面、頁面指示點及按鈕狀態的切換。
c、前面a中提到橫/豎屏轉換時Activity會重啟(再次調用onCreate()方法,還有其他一些原因也會引起該結果),那么就需要暫時記錄重啟前用戶看到哪個頁面,以便重啟后能馬上恢復。代碼95-99行重載了Acticity的onSaveInstanceState(),利用變量mStep作為頁面的索引。Activity重啟后,獲取mStep值並恢復引導界面的工作由onCreate()方法完成。
d、手勢識別類TutorialImageGesture重載的方法onFling(),當手勢滑動的斜率大於1.5或水平距離小於500時,設定此種情形為不滿足頁面切換條件,不進行頁面切換;否則,根據水平方向上的X坐標來判斷向左還是向右切換頁面。
4、結果圖
雖然界面寒酸,還是拉出來溜溜。
四張引導界面(細心的朋友會發現其實是一張圖片截成了四部分):
手指在屏幕的圖片上進行滑動時,頁面會進行相應的切換;按SKIP、DONE按鈕或Back鍵時引導過程結束,App界面出現。
注意,當點擊界面上的SKIP或者DONE按鈕時,如代碼169-178行打開App對應的Activity,並設置其Flag屬性為Intent.FLAG_ACTIVITY_CLEAR_TOP,效果和按手機Back類似,將引導Activity徹底銷毀。
5、后記
上面的實現過程在顯示主要引導信息(圖片)時采用的是ImageView組件,那么當頁面向左/右變動時只能靠很笨的方法。后來改用ViewPager組件再次進行了實現,當用戶在屏幕上進行左/右滑動時可以調用其showNext()/showPrevious()方法來進行頁面的跳轉。
同時,還有幾個地方可以進行簡化處理。
1、頁面下方指示點的變換,將四個ImageView變量(mIndicate1/mIndicate2/mIndicate3/mIndicate4)寫成一個數組mIndicates[],在頁面變化時只需要兩句代碼就可以實現指示點做相應的改變,而不是通過if來判斷3-4次。如頁面左滑的處理代碼:
1 mIndicates[mStep].setBackgroundResource(R.drawable.asus_tutorial_indicator_dot); 2 mIndicates[mStep-1].setBackgroundResource(R.drawable.asus_tutorial_indicator_page);
2、在橫/豎屏切換時,ViewFlipper組件的顯示頁面也要進行相應的改變,因為其初始化時是顯示自身包含的第一個元素。ViewPager類提供了一個很好用的方法setDisplayedChild(int index),可以讓其直接顯示指定位置的子元素。而不用傻傻地通過循環來進行頁面的跳轉,雖然也可以達到目的。如用for循環實現的代碼為:
1 for(int i=0;i<mStep;++i){ 2 mViewFlipperImage.showNext(); 3 }