一个简单的基于Leaflet样式的多图层安卓地图应用

BULABULA

这是 Mobile Cartography 课程的 project,看DDL还有四天就不慌不慢的花了三个半天写了个差不多,但是因为心态爆的太早了导致获取用户地理位置的功能一直出现fatal error虽然后来发现问题很明显却还是花了整整一天半才改掉。就很气。就决定来写这篇文章降降燥。
Map in Colour 的设计初衷是希望能给那些喜欢自由的探索一个城市的少年们一个较为直观的,城市内功能性POI分布的类别地图。
无需太多复杂的功能,我就是想展示,那条大街上密布餐馆,而那条大街上全是博物馆etc.....
Git地址.

Map in Colour
Map in Colour

主界面UI部分结构介绍

用DrawerLayout+Toolbar实现。
DrawerLayout 的第一部分子布局既是主视图,第二部分android.support.design.widget.NavigationView则是侧边栏。可参考简书这篇文章,Android DrawerLayout.
主布局文件activity_maps.xml结构如下:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.widget.DrawerLayout>
      <LinearLayout>
       |    <android.support.v7.widget.Toolbar>
       |    <RelativeLayout>
       |     |     <Button>
       |     |     <WebView>
       |     </RelativeLayout>
     </LinearLayout>
     <android.support.design.widget*NavigationView... >
</android.support.v4.widget.DrawerLayout>

在LinerLayout主视图内,布局从上到下为一个Toolbar,一个RelativeLayout /(Button+WebView).
这个Button是因为leaflet地图没有提供好用的用户位置location API,必须要自己写一个按钮来实现Location+SetMapView的功能. WebView 用来显示HTML文件.

A WebView is a View that displays web pages. This class is the basis upon which you can roll your own web browser or simply display some online content within your Activity. It uses the WebKit rendering engine to display web pages and includes methods to navigate forward and backward through a history, zoom in and out, perform text searches and more.
Note that, in order for your Activity to access the Internet and load web pages in a WebView, you must add the INTERNET permission to your Android Manifest file as a child of the <manifest> element:
<uses-permission android:name="android.permission.INTERNET" />
By default, a WebView provides no browser-like widgets, does not enable JavaScript and web page errors are ignored. If your goal is only to display some HTML as a part of your UI, this is probably fine; the user won't need to interact with the web page beyond reading it, and the web page won't need to interact with the user. If you actually want a full-blown web browser, then you probably want to invoke the Browser application with a URL Intent rather than show it with a WebView.

所以,也就是说,用户是和这个MapView没有任何互动的. 但是当然这个工程也没有考虑这个需求 XD

主界面里Toolbar, DrawerLayout, NavigationView,WebView的初始化

Toolbar

首先,在主Activity,MapsActivity.java里初始化Toolbar. 最后一行 myToolbar.inflateMenu 用来关联一个menu文件夹下的menu布局文件,实现 Toolbar 右侧的下拉菜单.

Toolbar myToolbar = (Toolbar) findViewById(my_toolbar);
myToolbar.setTitle("Map in colour");
myToolbar.setTitleTextColor(Color.WHITE); 
myToolbar.inflateMenu(R.menu.button_clear);

Toolbar右侧下拉菜单布局是放在res/menu文件夹下的button_clear.xml文件.
内部有两个item,help和info.
点击Help后启动第二个Activity来展示图例说明.
点击info后在主界面上显示一段文字(开发者信息).

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <item
        android:id="@+id/action_item"
        android:title="@string/menu_item_01"
        app:showAsAction="never" />
    <item
        android:id="@+id/action_item0"
        android:title="@string/menu_item_02"
        app:showAsAction="never" />
</menu>

设定点击menu中item的响应:

myToolbar.setOnMenuItemClickListener(new Toolbar.OnMenuItemClickListener() {
            public boolean onMenuItemClick(MenuItem item) {
                int menuItemId = item.getItemId();
                if (menuItemId == R.id.action_item) {
                    onClickStartNewActivity();
                } else if (menuItemId == R.id.action_item0) {
                    Toast.makeText(MapsActivity.this , "..........", Toast.LENGTH_LONG).show();
                }
                return true;
            }
        });

DrawerLayout

然后是继续在Activity 初始化DrawerView以实现Toolbar左侧的坍缩按钮和侧边栏的关联.
参考Android开发指南 ActionBarDrawerToggle
参数如下:
ActionBarDrawerToggle(Activity activity, DrawerLayout drawerLayout, int drawerImageRes, int openDrawerContentDescRes, int closeDrawerContentDescRes)

初始化如下:

final DrawerLayout mDrawerlayout = (DrawerLayout) findViewById(R.id.drawer_layout);
        ActionBarDrawerToggle toggle = new ActionBarDrawerToggle(
                this,
                mDrawerlayout,
                myToolbar,
                R.string.app_name,
                R.string.app_name
        );
        mDrawerlayout.addDrawerListener(toggle);
        toggle.syncState();

NavigationView

然后是继续初始化侧边栏布局NavigationView.

        final NavigationView navigationView = (NavigationView) findViewById(R.id.nav_view);
        navigationView.setItemIconTintList(null);

侧边栏NavigationView由两部分组成.
在主布局文件activity_maps.xml中可以看到:

<android.support.design.widget.NavigationView
        android:id="@+id/nav_view"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        android:fitsSystemWindows="true"
        android:layout_centerHorizontal="true"
        android:layout_centerVertical="true"
        app:headerLayout="@layout/nav_header"
        app:menu="@menu/activity_main_drawer"
        app:itemTextColor="@color/drawer_item"
        app:itemIconTint="@color/drawer_item_tint"
        />

这个 app:headerLayoutapp:menu 分别指定了侧边栏的头部分布局,和头部分下面的菜单布局。
既,上部图片区,和下部列表区。
头部nav_header.xml:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="192dp"
    android:theme="@style/ThemeOverlay.AppCompat.Dark">
    <ImageView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/colorAccent"
        android:src="@drawable/logopic"
        android:scaleType="centerCrop"/>
</FrameLayout>

菜单部:

<?xml version="1.0" encoding="utf-8"?>
<menu
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <item android:title="@string/selectLayer">
        <menu>
            <item
                android:id="@+id/nav_eat"
                android:icon="@drawable/ic_1"
                android:title="@string/eat"/>
             ......         
            <item
                android:id="@+id/nav_wc"
                android:icon="@drawable/ic_8"
                android:title="@string/wc"/>
        </menu>
    </item>
</menu>

为什么结构是item内部menu内部item呢?
因为我希望这个菜单可以多个item选中,而不是点选一个后之前check掉的那个又被清除了.
而:

Checkable items appear only in submenus or context menus.

然后是设定点击menu里面item的响应:

navigationView.setNavigationItemSelectedListener(newNavigationView.OnNavigationItemSelectedListener() {
       @Override
        public boolean onNavigationItemSelected(MenuItem item) {......}
}

WebView

初始化WebView用来显示地图.

 final WebView webView = (WebView) findViewById(webview);
        webView.getSettings().setJavaScriptEnabled(true);
        webView.addJavascriptInterface(this, "javatojs");
        webView.loadUrl("file:///android_asset/map.html");

这里需要在java文件夹下新建一个asset文件夹用来存放HTML文件.

<!DOCTYPE html>
<html>
<head>
    <title>Custom Map</title>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.0.1/dist/leaflet.css"/>
    <script src="https://unpkg.com/leaflet@1.0.1/dist/leaflet.js"></script>
    <style>......</style>
</head>
<body>
<div id='map'></div>
<script>......</script>
</body>
</html>

script里面为关键内容.
先设定地图的初始显示中心和缩放等级:
var map = L.map('map', {center: [51.051877, 13.741517],zoom: 15});

再加入你先设计好样式的底图图层链接:

var basemap = L.tileLayer('HERE YOUR BASE MAP URL',{
           id: 'mapbox.streets'
       });
    basemap.addTo(map);

接下来是一个用来传入获取到的用户坐标重设map中心点的功能:

 function relocation(i,j){
     var london = new L.LatLng(i,j);
     map.setView(london, 17);
    }

在这里,我们需要Mapbox来发布自定义地图样式.

For customizing the style of our Mapbox map, please login in to Mapbox Studio or create an account if you don’t have one yet. After logging in, select the option New style. You can give your style a name and choose if you want to modify an existing Mapbox style or create a completely new one whereas the last option is less comfortable since the new style needs to be build up from the bottom. After clicking Create you can modify the look of all objects in the map up to your needs. Therefore select an object for opening an additional window. The tab Style contains control elements for editing the appearance of the respective object, the tab Select data offers further options, for instance defining a scale range in which the selected object is visible in the map. Furthermore more you can add, hide, duplicate and delete layers.
After creating your own map style, click on the Publish-button in the upper left corner. A pop up window will inform you whether the publishing process was successful. If so, click Preview, develop & use. On the new page scroll down to the section Develop with this style and switch from the Mapbox to the Leaflet option. A Leaflet URL is displayed to you which needs to be copied and used for replacing the URL in the tileLayer-definition in map.html. Now a map with the style you have just created will be displayed in the Android app after re-running it.

Leaflet URL
Leaflet URL

在这个工程里,我们一共发布了九个图层,分别是一个黑底白色label的底图,和剩下八个单独色点背景透明的,选择好了POI类别的图层.
在script里面接着声明他们,并写8个添加他们的function,和8个删除他们的function :
(好吧小白表示这里代码冗余也不晓得怎么管了,以后再学习处理吧.....)

var layer1 = L.tileLayer('https://api.mapbox.com......',
        {
            id: 'mapbox.streets'
        });
function addlayer1(){
        layer1.addTo(map);
    }
function removelayer1(){
         map.removeLayer(layer1);
    }

到这里,HTML就全部写完了,这八个add layer的方法和上面提到的侧边栏NavigationView里面menu的每个item相对应,当public boolean onNavigationItemSelected(MenuItem item) {......}触发,在此方法内部实现分别调用JS里面的function来把图层添加或移除.

获得用户地理位置的方法

首先在Mainifest里添加许可:
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

初始化LocationListener,用于捕获用户地理位置变更和相应的响应事件 .

locationListener = new LocationListener() {
            public void onLocationChanged(Location location) {
            }
            public void onProviderDisabled(String provider) {
            }
            public void onProviderEnabled(String provider) {
            }
            public void onStatusChanged(String provider, int status, Bundle extras) {
            }
};

LocationManager获取系统(定位LOCATION_SERVICE)服务 .
locationManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE);

然而,在调用任何LocationManager方法之前,我们被要求必须先检查用户许可.
这里的if就是,如果检测到我们还没有问用户申请过许可,就必须在这里先申请.

 if (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
            // TODO: Consider calling
            //    ActivityCompat#requestPermissions
            // here to request the missing permissions, and then overriding
            //   public void onRequestPermissionsResult(int requestCode, String[] permissions,
            //                                          int[] grantResults)
            // to handle the case where the user grants the permission. See the documentation
            // for ActivityCompat#requestPermissions for more details.
            ActivityCompat.requestPermissions(this, LOCATION_PERMS , 1340);
            return;
        }

一旦当我们获得过了用户许可,检查许可就可以通过,则跳过if语句,调用LocationManager方法被允许.

location = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER);
final double latitude = location.getLatitude();
final double longitude = location.getLongitude();

上面提到,如果许可检查失败,在if里面我们需要向用户提出许可申请.
这里要使用 ActivityCompat.requestPermissions(this, LOCATION_PERMS , 1340) 方法.
根据安卓开放说明,三个变量如下:

requestPermissions(Activity activity, String[] permissions, int requestCode)

第二个变量 String[] permissions 在公共类开头以被声明.
final String[] LOCATION_PERMS = {android.Manifest.permission.ACCESS_FINE_LOCATION};
第三个变量也一样:
final int LOCATION_REQUEST = 1340;
根据 Android help

一旦 requestPermissions 被调用,会有一个pop up窗口弹出问用户申请许可.

If your app does not have the requested permissions the user will be presented with UI for accepting them. After the user has accepted or rejected the requested permissions you will receive a callback reporting whether the permissions were granted or not. Your activity has to implement ActivityCompat.OnRequestPermissionsResultCallback and the results of permission requests will be delivered to its onRequestPermissionsResult(int, String[], int[]) method.

当我们调用 requestPermissions 之后, 我们需要重写 onRequestPermissionsResult 方法,在
OnCreate() 外面. 如果用户返回结果是 "accept",接着即可调用 LocationManager的 Location updates方法了.

@Override
    public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
        switch (requestCode){
            case LOCATION_REQUEST:
                if (grantResults[0] == PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
                    locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER,1000, 0, locationListener);
                    onClickStartNewActivity();//First time start : show second view
                } else {
                    Toast.makeText(this, "Location cannot be obtained due to " + "missing permission.", Toast.LENGTH_LONG).show();
                }
                break;
        }
    }

我在这里多写了一句调用onClickStartNewActivity()方法来打开第二个Activity用以刷新主Activity (用户许可会被系统记住,再打开主Activity时 if 检查就会通过,if后面的代码就可以生效了 )

       final String position= "javascript:relocation("+latitude+","+longitude+")";
       final Button button = (Button) findViewById(R.id.locationbutton);
       button.setOnClickListener(new View.OnClickListener() {
            public void onClick(View v) {
                webView.loadUrl(position);
                //Toast.makeText(MapsActivity.this , position, Toast.LENGTH_LONG).show();
                // Perform action on click
            }

onClickStartNewActivity()方法如下:

public void onClickStartNewActivity() {
        Intent intent = new Intent(this, LegendActivity.class);
        startActivity(intent);
 }

第二个Activity就是说明界面. 较为简单在这里就不细说了.

应用语言自适应

所有的系统String都放入资源文件夹内的strings.xml内.

<resources>
    <string name="app_name">Map in colour</string>
    <string name="title_activity_maps">Map in colour</string>
    <string name="title_help">How does it work</string>
    <string name="menu_item_01">Help</string>
    <string name="menu_item_02">Info</string>
    <string name="selectLayer">Select layers</string>
    <string name="eat">Eat&Drink</string>
    <string name="buy">Buy&Buy</string>
    <string name="culture">Art&Culture</string>
    <string name="club">Entertain&Nightlife</string>
    <string name="transport">Come&Go</string>
    <string name="hospital">Hospital&Pharmacy</string>
    <string name="bank">Bank&Exchange</string>
    <string name="wc">Pee&Poo</string>
</resources>

再在res文件夹内新建一个名为values-zh的文件夹. 也放入一个strings.xml.
但这个文件设定为中文.

<resources>
    <string name="app_name">彩色地图</string>
    <string name="title_activity_maps">彩色地图</string>
    <string name="title_help">图层说明</string>
    <string name="menu_item_01">帮助</string>
    <string name="menu_item_02">信息</string>
    <string name="selectLayer">选择图层</string>
   <string name="eat">吃吃喝喝</string>
    <string name="buy">买买买</string>
    <string name="culture">艺术与文化</string>
    <string name="club">酒吧与夜生活</string>
    <string name="transport">交通枢纽</string>
    <string name="hospital">医院</string>
    <string name="bank">银行与货币兑换</string>
    <string name="wc">厕所</string>
</resources>

这样,应用就会根据系统设定的语言来自主选择从哪一个资源文件内选择string了.

结尾处的BULABULA

现在的问题是,POI在小比例尺的视图中都会消失不见,这个scale level能否改变一下,使得在小比例尺视图中可以看到全城的概览就是下一个需要考虑的问题啦~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335

推荐阅读更多精彩内容