Bluetooth Low Energy on Android, Part 3
AndroidIn this final installment, we will dive into the Client Characteristic Configuration Descriptor, which we’ll use to control notifications.
MapView
! You can see a GoogleMap
inside of it! Find your location! Pan! Zoom! Unzoom! There are too many amazing features to talk about. We should add one to our app like… now.
Google has graciously provided us with a MapFragment
that can easily be added to any Activity, but what if you don’t want a full Fragment
, or you want to add a MapView
to a RecyclerView
?
First add the maps dependency:
compile 'com.google.android.gms:play-services-maps:6.5.87'
And add this fragment into your activity layout:
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/activity_fragment_mapview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:name="com.google.android.gms.maps.MapFragment"/>
You’ll end up with this:
You’re welcome. Post over, right?
No! You should yearn for more.
You now have a Fragment
that wraps an instance of a MapView
. That’s neat and all, but what can you do with it? Anything you want! As long as you enjoy using Activities, everything is fine and dandy.
But here at the Ranch, Fragments are all the rage, and we prefer to have the majority of the controller and view logic in the Fragment when possible. If you use support fragments like we do, you will want to use SupportMapFragment
instead of MapFragment
. It gives you the exact same result, but subclasses the support Fragment
class instead.
At this point, you now have a full screen MapView
, but not many options for customizing the view. Adding additional views around the <fragment>
tag gets messy. What if you only want a MapView
, not a whole Fragment
? Why not just use it directly?
The docs will tell you that the MapView
resides in the com.google.android.gms.maps
package. Let’s create a simple layout for your host fragment.
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:text="@string/header"
style="@style/LargeTextViewCentered"/>
<!-- Note: We use a reverse DNS naming style
ie: fragment (layout type)
+ embedded_map_view (name)
+ (optional view name if only one)
+ mapview (view object type) -->
<com.google.android.gms.maps.MapView
android:id="@+id/fragment_embedded_map_view_mapview"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
<TextView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:text="@string/footer"
style="@style/LargeTextViewCentered"/>
</LinearLayout>
Running the app, you get… Nothing?
Double checking the XML, it looks like there should be a map between the header and footer TextView
s. Consult the docs again and… Oh. It looks like the MapView
needs to have the Activity
/ Fragment
lifecycle methods sent to it.
The docs suggest sending onCreate
, onResume
, onPause
, onDestroy
, onSaveInstanceState
and onLowMemory
. Let’s try wiring those up.
protected MapView mMapView;
@Override
public void onResume() {
super.onResume();
if (mMapView != null) {
mMapView.onResume();
}
}
@Override
public void onPause() {
if (mMapView != null) {
mMapView.onPause();
}
super.onPause();
}
@Override
public void onDestroy() {
if (mMapView != null) {
try {
mMapView.onDestroy();
} catch (NullPointerException e) {
Log.e(TAG, "Error while attempting MapView.onDestroy(), ignoring exception", e);
}
}
super.onDestroy();
}
@Override
public void onLowMemory() {
super.onLowMemory();
if (mMapView != null) {
mMapView.onLowMemory();
}
}
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
if (mMapView != null) {
mMapView.onSaveInstanceState(outState);
}
}
In onDestroy
, we add a try/catch
because sometimes the GoogleMap
inside of the MapView
is null, causing an exception. It’s unpredictable, so we can only guard against it. Since we are calling mMapView.onDestroy
to allow it to clean up its possibly null GoogleMap
, then our job is done and we can safely ignore the resulting exception.
All that’s left is to pass onCreate
to mMapView
. It doesn’t quite yet exist in the Fragment.onCreate
, so let’s put it in the onCreateView
.
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup parent, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_embedded_map_view, parent, false);
mMapView = (MapView) view.findViewById(R.id.fragment_embedded_map_view_mapview);
mMapView.onCreate(savedInstanceState);
return view;
}
Success! We now have a MapView
in our Fragment
.
Man, that thing looks great! The best part is that you can now rearrange and access it just like any other View
. Now call mMapView.getMapAsync(OnMapReadyCallback callback)
and have fun with the map!
So what else can you do with a MapView
?
On a recent project, the client wanted maps in a RecyclerView
. The tricky part here is that the MapView
list item view will be created, reused and possibly destroyed at the RecyclerView
’s discretion. Its lifecycle will be out of sync from the hosting Fragment
. Let’s see what we can do to get this working.
First, create the view and setup the RecyclerView
.
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup parent, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_recycler_view, parent, false);
mRecyclerView = (RecyclerView) view.findViewById(R.id.fragment_recycler_view_recyclerview);
mRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
RecyclerViewMapViewAdapter recyclerViewAdapter = new RecyclerViewMapViewAdapter();
mRecyclerView.setAdapter(recyclerViewAdapter);
return view;
}
Now the Adapter
:
private class RecyclerViewMapViewAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
@Override
public int getItemCount() {
return 10;
}
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
MapViewListItemView mapViewListItemView = new MapViewListItemView(getActivity());
return new MapViewHolder(mapViewListItemView);
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {}
}
The MapViewHolder
only holds onto our custom MapViewListItemView
:
public class MapViewHolder extends RecyclerView.ViewHolder {
private MapViewListItemView mMapViewListItemView;
public MapViewHolder(MapViewListItemView mapViewListItemView) {
super(mapViewListItemView);
mMapViewListItemView = mapViewListItemView;
}
}
And now the MapViewListItemView
:
public class MapViewListItemView extends LinearLayout {
protected MapView mMapView;
public MapViewListItemView(Context context) {
this(context, null);
}
public MapViewListItemView(Context context, AttributeSet attrs) {
super(context, attrs);
View view = LayoutInflater.from(getContext()).inflate(R.layout.list_item_map_view, this);
mMapView = (MapView) view.findViewById(R.id.list_item_map_view_mapview);
setOrientation(VERTICAL);
}
}
The MapViewListItemView
also contains a TextView
as a simple divider, and to help you know where the MapView
should be. Just to see what is working so far, let’s build and launch.
There should be MapView
s underneath each of those “Text”s. So just like before, lifecycle events need to be forwarded from the hosting fragment to each MapViewListItemView
. Start by giving MapViewListItemView
a few methods to pass each event on to its MapView
.
public void mapViewOnCreate(Bundle savedInstanceState) {
if (mMapView != null) {
mMapView.onCreate(savedInstanceState);
}
}
public void mapViewOnResume() {
if (mMapView != null) {
mMapView.onResume();
}
}
...
You get the idea. Now, when should these be called? We create each MapViewListItemView
in onCreateViewHolder
where we stored it into a MapViewHolder
. We need to call MapView.onCreate
here, but it needs a savedInstanceState
. It will probably be used to save map settings like zoom and pan, but let’s not worry about that just yet and attempt to pass null
.
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
MapViewListItemView mapViewListItemView = new MapViewListItemView(getActivity());
mapViewListItemView.mapViewOnCreate(null);
return new MapViewHolder(mapViewListItemView);
}
Great! Now the MapView
needs to know when to resume.
onBind
is when the view is setup after a recycle, but we need to call through the MapViewHolder
.
public void mapViewListItemViewOnResume() {
if (mMapViewListItemView != null) {
mMapViewListItemView.mapViewOnResume();
}
}
And now use it:
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
MapViewHolder mapViewHolder = (MapViewHolder) holder;
mapViewHolder.mapViewListItemViewOnResume();
}
Let’s see if there is a map or two.
Excellent! What of the other lifecycle methods? Surely those are needed as well? They may be unnecessary, but the docs recommend it.
In order to do this, add the MapViewListItemView
to a List
in onCreateViewHolder
. Then on each Fragment
lifecycle event we attempt to call into all views. Bear in mind that although this is a very rudimentary approach, it complies with the docs.
@Override
public void onResume() {
super.onResume();
for (MapViewListItemView view : mMapViewListItemViews) {
view.mapViewOnResume();
}
}
@Override
public void onPause() {
for (MapViewListItemView view : mMapViewListItemViews) {
view.mapViewOnPause();
}
super.onPause();
}
@Override
public void onDestroy() {
for (MapViewListItemView view : mMapViewListItemViews) {
view.mapViewOnDestroy();
}
super.onDestroy();
}
@Override
public void onLowMemory() {
super.onLowMemory();
for (MapViewListItemView view : mMapViewListItemViews) {
view.mapViewOnLowMemory();
}
}
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
for (MapViewListItemView view : mMapViewListItemViews) {
view.mapViewOnSaveInstanceState(outState);
}
}
You may notice that sometimes the GoogleMap has not started by the time you need it. This is a common problem, and it can be fixed by adding MapsInitializer.initialize
to your Fragment.onCreate
. This will ensure the Google Maps Android API has been started in time for your maps. It will also set up map-related classes and allow you to access them before the GoogleMap is finished initializing. You may be wondering why we passed null
into our mapViewListItemView.mapViewOnCreate
, because this gives the MapView
nowhere to save any customizations.
Customizations will be lost during low memory or orientation changes. These include map markers, camera location, zoom levels, etc. To avoid this undesired behavior, you will need a rather complex system of Bundle
management. Each Adapter
item position will need its own Bundle
, not just every view or holder. The MapView
’s state will need to be saved each time the view is detached and recycled, and also during Fragment.onSaveInstanceState
for the ones that are still visible.
If, for some reason, you choose to pass the host Fragment
s savedInstanceState
to the MapView
, be warned that there could be issues. If any non-MapView
related data is saved by you, a library, or even a Google view class (i.e., RecyclerView
) into the Bundle
, it will need to be “sanitized.” It would seem that the MapView
attempts to walk through all the information in the savedInstanceState
and instantiate it, which can manifest as a ClassNotFoundException
or BadParcelableException
. If this happens, you will need to remove the data from the Bundle
before passing it on to the MapView.onCreate
.
In this final installment, we will dive into the Client Characteristic Configuration Descriptor, which we’ll use to control notifications.
In (https://nerdranchighq.wpengine.com/blog/bluetooth-low-energy-part-1/) we set up our BLE Server and Client and were able to connect them. Now it's time to take a look...
There are many resources available on Bluetooth on Android, but unfortunately many are incomplete snippets, use out-of-date concepts, or only explain half of the...