一步步实现一个城市选择器
主要包含以下内容:
1、自动定位所在城市
2、热门城市列表展示
3、所有城市列表的展示
4、输入城市名或者城市拼音搜索对应城市
5、右侧的slidebar城市列表导航栏
请大家先下载Demo然后再一边看demo一边看博客。因为博客里很多代码因为比较简单就不贴了。
首先我们先搭建基本的UI:
分析效果图,我们需要一个顶部titleview,一个搜索框,一个定位功能的view,一个展示热门城市的view,一个侧边栏view和一个listview。
顶部titleView:
这里有一些需要注意的地方:
我们在新建工程的时候,Androidstudio会自动生成一个style作为我们的主题:
@color/colorPrimary
@color/colorPrimaryDark
@color/colorAccent
android:theme="@style/AppTheme"
这个默认的主题是带有actionbar的,如果我们要去掉这个actionbar,首先需要把DarkActionBar改为NoActionBar,因为使用AppCompatActivity的时候,Activity必须使用Theme.AppCompat主题及其子主题,所以我们的自定义的HD_NoActionBar样式必须继承这个主题:
@color/colorPrimary
@color/colorPrimaryDark
@color/colorAccent
true
false
然后引用这个style:
android:theme="@style/AppTheme.NoActionBar"
接下来写我们的头布局title_view.xml:
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/light_blue">
android:id="@+id/back"
style="@style/Widget.AppCompat.ActionButton"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:scaleType="center"
android:src="@mipmap/ic_back"
tools:ignore="ContentDescription"/>
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="@string/select_city"
android:textSize="20sp"
android:textColor="@color/white"/>
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/white"/>
布局返回按钮用一个ImageView,title用一个Textview。
然后在我们的主布局里使用标签引入头布局:
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/light_blue"
android:orientation="vertical">
现在的效果是这样的:
搜索框布局searchview
search_view/xml:
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="36dp"
android:layout_marginBottom="8dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:background="@drawable/search_box_bg">
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:paddingLeft="8dp"
android:paddingRight="8dp"
android:src="@mipmap/ic_search"
android:scaleType="center"
tools:ignore="ContentDescription"/>
android:id="@+id/et_search"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="match_parent"
android:background="@null"
android:gravity="center_vertical"
android:hint="@string/hint_search_box"
android:textColorHint="@color/deep_blue"
android:inputType="text"
android:singleLine="true"
android:textColor="@color/deep_blue"
android:textSize="14sp"
tools:ignore="RtlHardcoded"/>
android:id="@+id/iv_search_clear"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:paddingLeft="8dp"
android:paddingRight="8dp"
android:src="@mipmap/ic_search_clear"
android:visibility="gone"
tools:ignore="ContentDescription"/>
然后在主布局里引入这个布局:
搜索框的布局也非常简单,就不说明了。
现在的效果:
城市列表
接下来的定位城市、热门城市、以及所有城市的列表我们使用一个Listview搞定,让Listview加载三种不同的布局来展示。
定位城市和所有城市列表好说,这个热门城市的UI该怎么做呢?我们准备使用gridview来做,在listview里嵌套gridview会遇到gridview只能显示一行的问题,我们先重现这个问题,然后再分析怎么解决。
listview需要一个adapter适配器,adapter需要一个数据源,我们的数据源存放在一个db数据库里,所以我们要构建一个数据库操作类,从数据库中取出这些城市然后展示出来。这一段的代码比较多,前方高能预警(^__^)
我们把要做的事情按步骤划分:
1、导入数据库文件
2、构建City对象,用户存储城市信息
3、创建DBManager用来操作数据库,将查询到的数据传递给adapter
4、编写定位城市、热门城市、所有城市三种不同的item布局
5、编写adapter,在adapter里加载三种item布局
6、编写gridview热门城市的item布局
7、实现gridview的adapter
1、建立assets文件,并把db文件放在assets目录下:
2、City对象
City.Java:
publicclassCity{
privateStringname;
privateStringpinyin;
publicCity(){}
publicCity(Stringname,Stringpinyin){
this.name=name;
this.pinyin=pinyin;
}
publicStringgetName(){
returnname;
}
publicvoidsetName(Stringname){
this.name=name;
}
publicStringgetPinyin(){
returnpinyin;
}
publicvoidsetPinyin(Stringpinyin){
this.pinyin=pinyin;
}
}
数据库操作类:
DBManager.java:
publicclassDBManager{
privatestaticfinalStringASSETS_NAME="china_cities.db";
privatestaticfinalStringDB_NAME="china_cities.db";
privatestaticfinalStringTABLE_NAME="city";
privatestaticfinalStringNAME="name";
privatestaticfinalStringPINYIN="pinyin";
privatestaticfinalintBUFFER_SIZE=1024;
privateStringDB_PATH;
privateContextmContext;
//初始化
publicDBManager(Contextcontext){
this.mContext=context;
DB_PATH=File.separator+"data"
+Environment.getDataDirectory().getAbsolutePath()+File.separator
+context.getPackageName()+File.separator+"databases"+File.separator;
}
//保存数据库到本地
@SuppressWarnings("ResultOfMethodCallIgnored")
publicvoidcopyDBFile(){
Filedir=newFile(DB_PATH);
if(!dir.exists()){
dir.mkdirs();
}
FiledbFile=newFile(DB_PATH+DB_NAME);
if(!dbFile.exists()){
InputStreamis;
OutputStreamos;
try{
is=mContext.getResources().getAssets().open(ASSETS_NAME);
os=newFileOutputStream(dbFile);
byte[]buffer=newbyte[BUFFER_SIZE];
intlength;
while((length=is.read(buffer,0,buffer.length))>0){
os.write(buffer,0,length);
}
os.flush();
os.close();
is.close();
}catch(IOExceptione){
e.printStackTrace();
}
}
}
/
读取所有城市
@return
/
publicListgetAllCities(){
SQLiteDatabasedb=SQLiteDatabase.openOrCreateDatabase(DB_PATH+DB_NAME,null);
Cursorcursor=db.rawQuery("selectfrom"+TABLE_NAME,null);
Listresult=newArrayList<>();
Citycity;
while(cursor.moveToNext()){
Stringname=cursor.getString(cursor.getColumnIndex(NAME));
Stringpinyin=cursor.getString(cursor.getColumnIndex(PINYIN));
city=newCity(name,pinyin);
result.add(city);
}
cursor.close();
db.close();
Collections.sort(result,newCityComparator());
returnresult;
}
/
通过名字或者拼音搜索
@paramkeyword
@return
/
publicListsearchCity(finalStringkeyword){
SQLiteDatabasedb=SQLiteDatabase.openOrCreateDatabase(DB_PATH+DB_NAME,null);
Cursorcursor=db.rawQuery("selectfrom"+TABLE_NAME+"wherenamelike\"%"+keyword
+"%\"orpinyinlike\"%"+keyword+"%\"",null);
Listresult=newArrayList<>();
Citycity;
while(cursor.moveToNext()){
Stringname=cursor.getString(cursor.getColumnIndex(NAME));
Stringpinyin=cursor.getString(cursor.getColumnIndex(PINYIN));
city=newCity(name,pinyin);
result.add(city);
}
cursor.close();
db.close();
Collections.sort(result,newCityComparator());
returnresult;
}
/
a-z排序
/
privateclassCityComparatorimplementsComparator{
@Override
publicintcompare(Citylhs,Cityrhs){
Stringa=lhs.getPinyin().substring(0,1);
Stringb=rhs.getPinyin().substring(0,1);
returna.compareTo(b);
}
}
}
这个类使用SQLiteDatabase来管理数据库,同事写了一个排序类CityComparator用来对城市按照首字母进行排序
定位城市的布局:
view_locate_city.xml:
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingBottom="8dp"
tools:ignore="RtlHardcoded">
style="@style/LetterIndexTextViewStyle"
android:text="@string/located_city"/>
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:background="@color/content_bg">
android:id="@+id/layout_locate"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:minWidth="96dp"
android:paddingLeft="8dp"
android:paddingRight="8dp"
android:gravity="center"
android:clickable="true"
android:background="@drawable/overlay_bg">
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@mipmap/ic_locate"
tools:ignore="ContentDescription"/>
android:id="@+id/tv_located_city"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:text="@string/locating"
android:textSize="16sp"
android:textColor="@color/white"/>
然后是所有城市的布局:
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
tools:ignore="RtlHardcoded">
android:id="@+id/tv_item_city_listview_letter"
style="@style/LetterIndexTextViewStyle"
android:textSize="18sp"
android:clickable="false"/>
android:id="@+id/tv_item_city_listview_name"
android:layout_width="match_parent"
android:layout_height="48dp"
android:paddingLeft="16dp"
android:paddingRight="@dimen/side_letter_bar_width"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:gravity="center_vertical"
android:textSize="@dimen/city_text_size"
android:textColor="@color/light_blue"/>
android:layout_width="match_parent"
android:layout_height="1px"
android:layout_marginLeft="16dp"
android:layout_marginRight="@dimen/side_letter_bar_width"
android:background="@color/divider"/>
使用两个TextView一个用来显示城市的首字母,一个用来显示城市名字
上面的布局都很简单,接下来就是热门城市了:
如果我们使用gridview不做任何处理的话,最终效果是这样的:
不止在listview,gridview在其它任何可以滚动的view里都会出现这个问题,解决这个问题我们有固定的方案,那就是自定义一个gridview然后重写onMeasure方法,在onMeasure方法里,让gridview测量子view的高度,并全部显示出来。
代码其实非常简单:
publicclassWrapHeightGridViewextendsGridView{
publicWrapHeightGridView(Contextcontext){
this(context,null);
}
publicWrapHeightGridView(Contextcontext,AttributeSetattrs){
this(context,attrs,0);
}
publicWrapHeightGridView(Contextcontext,AttributeSetattrs,intdefStyleAttr){
super(context,attrs,defStyleAttr);
}
//如果把GridView放到一个垂直方向滚动的布局中,设置其高度属性为wrap_content,
//则该GridView的高度只有一行内容,其他内容通过滚动来显示。
//如果你想让该GridView的高度为所有行内容所占用的实际高度,则可以通过覆写GridView的onMeasure函数来修改布局参数
@Override
protectedvoidonMeasure(intwidthMeasureSpec,intheightMeasureSpec){
intheightSpec=MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE>>2,MeasureSpec.AT_MOST);
super.onMeasure(widthMeasureSpec,heightSpec);
}
}
这里重点理解makeMeasureSpec这个方法,publicstaticintmakeMeasureSpec(intsize,intmode)
这个是由我们给出的尺寸大小和模式生成一个包含这两个信息的int变量。这个int值有32位,其中高2位表示模式,低30位表示值。我们把Integer.MAX_VALUE>>2右移两位然后和MeasureSpec.AT_MOST合成一个新的int值。Integer.MAX_VALUE是INT类型的最大值是0xFFFFFFFF,所以这个值右移两位就表示新的合成的int值得低30位都是1。也就是说我们取最大值作为控件高度的最大值。
这样就可以了,效果:
接下来看一下adapter是怎么实现的:
CityListAdapter.java:
packagetest.study.select_city.adapter;
publicclassCityListAdapterextendsBaseAdapter{
privatestaticfinalintVIEW_TYPE_COUNT=3;
privateContextmContext;
privateLayoutInflaterinflater;
privateListmCities;
privateHashMapletterIndexes;
privateString[]sections;
privateOnCityClickListeneronCityClickListener;
privateintlocateState=LocateState.LOCATING;
privateStringlocatedCity;
publicCityListAdapter(ContextmContext,ListmCities){
this.mContext=mContext;
this.mCities=mCities;
this.inflater=LayoutInflater.from(mContext);
if(mCities==null){
mCities=newArrayList<>();
}
mCities.add(0,newCity("定位","0"));
mCities.add(1,newCity("热门","1"));
intsize=mCities.size();
letterIndexes=newHashMap<>();
sections=newString[size];
for(intindex=0;index //当前城市拼音首字母
StringcurrentLetter=PinyinUtils.getFirstLetter(mCities.get(index).getPinyin());
//上个首字母,如果不存在设为""
StringpreviousLetter=index>=1?PinyinUtils.getFirstLetter(mCities.get(index-1).getPinyin()):"";
if(!TextUtils.equals(currentLetter,previousLetter)){
letterIndexes.put(currentLetter,index);
sections[index]=currentLetter;
}
}
}
/
更新定位状态
@paramstate
/
publicvoidupdateLocateState(intstate,Stringcity){
this.locateState=state;
this.locatedCity=city;
notifyDataSetChanged();
}
/
获取字母索引的位置
@paramletter
@return
/
publicintgetLetterPosition(Stringletter){
Integerinteger=letterIndexes.get(letter);
returninteger==null?-1:integer;
}
@Override
publicintgetViewTypeCount(){
returnVIEW_TYPE_COUNT;
}
@Override
publicintgetItemViewType(intposition){
returnposition }
@Override
publicintgetCount(){
returnmCities==null?0:mCities.size();
}
@Override
publicCitygetItem(intposition){
returnmCities==null?null:mCities.get(position);
}
@Override
publiclonggetItemId(intposition){
returnposition;
}
@Override
publicViewgetView(finalintposition,Viewview,ViewGroupparent){
CityViewHolderholder;
intviewType=getItemViewType(position);
switch(viewType){
case0://定位
view=inflater.inflate(R.layout.view_locate_city,parent,false);
ViewGroupcontainer=(ViewGroup)view.findViewById(R.id.layout_locate);
TextViewstate=(TextView)view.findViewById(R.id.tv_located_city);
switch(locateState){
caseLocateState.LOCATING:
state.setText(mContext.getString(R.string.locating));
break;
caseLocateState.FAILED:
state.setText(R.string.located_failed);
break;
caseLocateState.SUCCESS:
state.setText(locatedCity);
break;
}
container.setOnClickListener(newView.OnClickListener(){
@Override
publicvoidonClick(Viewv){
if(locateState==LocateState.FAILED){
//重新定位
if(onCityClickListener!=null){
onCityClickListener.onLocateClick();
}
}elseif(locateState==LocateState.SUCCESS){
//返回定位城市
if(onCityClickListener!=null){
onCityClickListener.onCityClick(locatedCity);
}
}
}
});
break;
case1://热门
view=inflater.inflate(R.layout.view_hot_city,parent,false);
WrapHeightGridViewgridView=(WrapHeightGridView)view.findViewById(R.id.gridview_hot_city);
finalHotCityGridAdapterhotCityGridAdapter=newHotCityGridAdapter(mContext);
gridView.setAdapter(hotCityGridAdapter);
gridView.setOnItemClickListener(newAdapterView.OnItemClickListener(){
@Override
publicvoidonItemClick(AdapterView>parent,Viewview,intposition,longid){
if(onCityClickListener!=null){
onCityClickListener.onCityClick(hotCityGridAdapter.getItem(position));
}
}
});
break;
case2://所有
if(view==null){
view=inflater.inflate(R.layout.item_city_listview,parent,false);
holder=newCityViewHolder();
holder.letter=(TextView)view.findViewById(R.id.tv_item_city_listview_letter);
holder.www.shanxiwang.netname=(TextView)view.findViewById(R.id.tv_item_city_listview_name);
view.setTag(holder);
}else{
holder=(CityViewHolder)view.getTag();
}
if(position>=1){
finalStringcity=mCities.get(position).getName();
holder.name.setText(city);
//如果当前的item的城市的首字母和上一个城市的首字母相同,就不显示首字母否则就显示。
//这样就可以实现让所有城市根据首字母分类的效果了。
StringcurrentLetter=PinyinUtils.getFirstLetter(mCities.get(position).getPinyin());
StringpreviousLetter=position>=1?PinyinUtils.getFirstLetter(mCities.get(position-1).getPinyin()):"";
if(!TextUtils.equals(currentLetter,previousLetter)){
holder.letter.setVisibility(View.VISIBLE);
holder.letter.setText(currentLetter);
}else{
holder.letter.setVisibility(View.GONE);
}
holder.name.setOnClickListener(newView.OnClickListener(){
@Override
publicvoidonClick(Viewv){
if(onCityClickListener!=null){
onCityClickListener.onCityClick(city);
}
}
});
}
break;
}
returnview;
}
publicstaticclassCityViewHolder{
TextViewletter;
TextViewname;
}
publicvoidsetOnCityClickListener(OnCityClickListenerlistener){
this.onCityClickListener=listener;
}
publicinterfaceOnCityClickListener{
voidonCityClick(Stringname);
voidonLocateClick();
}
}
Listview加载不同的布局,请参考我的另一篇博客:
最主要的就是getItemViewType方法。
在adapter里使用LocateState类来标示不同的定位状态。代码比较多,但是都很好理解就不再解释了(其实是懒。。。)
gridview热门城市的item布局
就是一个Textview而已
gridview热门城市的adapter
请参考demo,就是一个简单的adapter
然后是侧边导航栏的实现:
写一个自定义view,
SideBar.java
publicclassSideBarextendsView{
privatestaticfinalString[]b={"定位","热门","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"};
privateintchoose=-1;
privatePaintpaint=newPaint();
privatebooleanshowBg=false;
privateOnLetterChangedListeneronLetterChangedListener;
privateTextViewoverlay;
publicSideBar(Contextcontext,AttributeSetattrs,intdefStyle){
super(context,attrs,defStyle);
}
publicSideBar(Contextcontext,AttributeSetattrs){
super(context,attrs);
}
publicSideBar(Contextcontext){
super(context);
}
/
设置悬浮的textview
@paramoverlay
/
publicvoidsetOverlay(TextViewoverlay){
this.overlay=overlay;
}
@SuppressWarnings("deprecation")
@Override
protectedvoidonDraw(Canvascanvas){
super.onDraw(canvas);
if(showBg){
canvas.drawColor(Color.TRANSPARENT);
}
intheight=getHeight();
intwidth=getWidth();
intsingleHeight=height/b.length;//单行字母高度
for(inti=0;i paint.setTextSize(getResources().getDimension(R.dimen.side_letter_bar_letter_size));
paint.setColor(getResources().getColor(R.color.deep_blue));
paint.setAntiAlias(true);
if(i==choose){
paint.setColor(getResources().getColor(R.color.gray_deep));
//paint.setFakeBoldText(true);//加粗
}
floatxPos=width/2-paint.measureText(b[i])/2;
floatyPos=singleHeighti+singleHeight;
canvas.drawText(b[i],xPos,yPos,paint);
paint.reset();
}
}
@Override
publicbooleandispatchTouchEvent(MotionEventevent){
finalintaction=event.getAction();
finalfloaty=event.getY();
finalintoldChoose=choose;
finalOnLetterChangedListenerlistener=onLetterChangedListener;
finalintc=(int)(y/getHeight()b.length);//获取字母的index
switch(action){
caseMotionEvent.ACTION_DOWN:
showBg=true;
if(oldChoose!=c&&listener!=null){
if(c>=0&&c listener.onLetterChanged(b[c]);
choose=c;
invalidate();
if(overlay!=null){
overlay.setVisibility(VISIBLE);
overlay.setText(b[c]);
}
}
}
break;
caseMotionEvent.ACTION_MOVE:
if(oldChoose!=c&&listener!=null){
if(c>=0&&c listener.onLetterChanged(b[c]);
choose=c;
invalidate();
if(overlay!=null){
overlay.setVisibility(VISIBLE);
overlay.setText(b[c]);
}
}
}
break;
caseMotionEvent.ACTION_UP:
showBg=false;
choose=-1;
invalidate();
if(overlay!=null){
overlay.setVisibility(GONE);
}
break;
}
returntrue;
}
@Override
publicbooleanonTouchEvent(MotionEventevent){
returnsuper.onTouchEvent(event);
}
publicvoidsetOnLetterChangedListener(OnLetterChangedListeneronLetterChangedListener){
this.onLetterChangedListener=onLetterChangedListener;
}
publicinterfaceOnLetterChangedListener{
voidonLetterChanged(Stringletter);
}
}
我们绘制了slidebar,并且重写了它的ontouch事件。当手指摁下和滑动的时候在布局中央显示当前城市的首字母,显示首字母的控件是一个Textview,这个Textview通过setOverlay方法传递进来。抬起的时候不显示。
现在的效果是这样:
UI总算实现的差不多了,接下来还有城市定位功能,搜索功能以及右侧导航功能的要实现。
搜索功能:
主要就是给edittext设置一个TextChangedListener,让它根据输入去数据库中查找数据并将数据传递给adapter,然后通过notifyDataSetChanged方法来更新UI。
代码请参照demo
右侧导航功能
当我们的手指在slidebar上滑动的时候会触发它的ontouch事件,然后通过回调将当前的字母传递回来,接着我们将点击的字母传递给mCityAdapter的getLetterPosition方法,来得到当前字母的位置,并通过mListview.setSelection(position);方法来改变listview的显示位置
代码参考demo
定位功能
这个需要接入百度地图的sdk
这个大家根据百度地图开发者中心的手册一点点来就可以了。
|
|