에몽이

place api 웹 쿼리 버젼 with auto complete 본문

android

place api 웹 쿼리 버젼 with auto complete

ian_hodge 2016. 12. 24. 15:09

Recently in one of my android applications I wanted to obtain the user’s location in terms of city and country that they can feed from their (edit) profile section. So one way to do this is basically have two dropdowns or dialogs (or even open an entirely new activity where the user can search through entities and select one). One of them would display all the countries in which, once a selection is made, the other one will show a restricted set of cities based on the prior country selection. Now the number of cities in the world is large, so to do this we’ll need to get a database that contains all the countries and cities and then make sure we can query that over HTTP to get the cities based on what the user types into the app (autocomplete box). We’ve to make sure the response is really quick and doesn’t cause lags. We can also bundle all the city and country data into our app but then that’ll blow up the apk size.

Anyway, while searching the web and trying out apps like Airbnb and Couchsurfing I figured Google has the Place Autocomplete service using which I can allow the user to type in a city name which’ll fetch all the cities containing that string from Google’s servers that can be presented in an AutoCompleteTextView.

Google Place Autocomplete API Android

So let’s see how to execute this.

Getting an API Key

The first thing to do is to get an API key from Google Developers Console. More on this here. You’ll find four different key options – server, browser, android and iOS – you should go for the browser one as according to their documentation Places API doesn’t work with Android or iOS API key and since the app is like a browser client, a server key doesn’t make sense.

Know your Limits

You should know about the usage limitation. All the information pertaining to that is available here.

Place Autocomplete Requests and Responses

You should go through the Place Autocomplete service documentationwhere you can learn everything about the requests and responses. Basically you’ll have to make a request to https://maps.googleapis.com/maps/api/place/autocomplete/output?parameterswhere output can be either json or xml indicating the type of expected response. There are 2 required GET parameters which are input that should contain the string (that the user will type) to use as the search term and key which should contain your API key. So then the URL will look something like this:

There are other optional parameters that you can read up on but the most important one that you’ll mostly use is the types parameter. You can pass various values to types like:

  • geocode – Sort of like broad-level (generic) addresses.
  • address – Fully specified addresses.
  • establishment – Business results.
  • (regions) – This will return results matching localitysublocalitypostal_codecountryadministrative_area_level_1administrative_area_level_2. Specific details for each of them can be found here.
  • (cities) – Return results matching localityadministrative_area_level_3.

You can try a JavaScript based Place Autocomplete demo here. In our case we only want cities, so here’s a sample request:

Here’s the type of response that you should expect:

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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
{
   "predictions" : [
      {
         "description" : "Bangalore, Karnataka, India",
         "id" : "0862832923832bfb1e46cbe843cdaa03a9ee8aa1",
         "matched_substrings" : [
            {
               "length" : 3,
               "offset" : 0
            }
         ],
         "place_id" : "ChIJbU60yXAWrjsR4E9-UejD3_g",
         "reference" : "CkQzAAAAUY01eTC-f7Z9vOzDiHFtEL0rLEXfgae0MfOPR8bE26gDDFceZ3AH0SNE44HK7v27noYrRKobbNiWQQ2E4HjRPBIQAM_qIcQgybTqiyKiGyZtkhoUiFs2XXjLEJ3jWUU-p_PGcbm8JH4",
         "terms" : [
            {
               "offset" : 0,
               "value" : "Bangalore"
            },
            {
               "offset" : 11,
               "value" : "Karnataka"
            },
            {
               "offset" : 22,
               "value" : "India"
            }
         ],
         "types" : [ "locality", "political", "geocode" ]
      },
      {
         "description" : "Bangarapet, Karnataka, India",
         "id" : "7496e1dc83b719d83d1f64dcacc1b0592bc1149b",
         "matched_substrings" : [
            {
               "length" : 3,
               "offset" : 0
            }
         ],
         "place_id" : "ChIJu4YuxDfprTsRHSqs7ubXIaw",
         "reference" : "CkQ0AAAAGsgimTlmv97VHo27L3dRXg0ieWui67PGx84buBY3byBdBecoLmV-IwnW93-RxFRWgQkYRsM3NCQ38UEoKG_VQBIQe7TArn4qHgds5g_Y1Jhl0hoUe0sawRU7Hazq-4jJ8_0RT5coqBY",
         "terms" : [
            {
               "offset" : 0,
               "value" : "Bangarapet"
            },
            {
               "offset" : 12,
               "value" : "Karnataka"
            },
            {
               "offset" : 23,
               "value" : "India"
            }
         ],
         "types" : [ "locality", "political", "geocode" ]
      },
      {
         "description" : "Bande Nalla Sandra, Karnataka, India",
         "id" : "bbdabbca92990bd63dcc6a300d54fdfdcc3f5199",
         "matched_substrings" : [
            {
               "length" : 3,
               "offset" : 0
            }
         ],
         "place_id" : "ChIJseY83P9rrjsRj2k7EToO7Qs",
         "reference" : "CkQ8AAAA88Q9tpa3SB4oz5t00LapY--mX1kMNOhzO6yQxATNcRTsJvseqWNvu_6xwQm15r55XjV13uvElBa-FHLisNJv-BIQ7jt7wr2lw1Cf4kYuPCXH5BoU3d_k-Oyyh75-uYd-cqsEHZjuCKA",
         "terms" : [
            {
               "offset" : 0,
               "value" : "Bande Nalla Sandra"
            },
            {
               "offset" : 20,
               "value" : "Karnataka"
            },
            {
               "offset" : 31,
               "value" : "India"
            }
         ],
         "types" : [ "locality", "political", "geocode" ]
      },
      {
         "description" : "Bandipur, Karnataka, India",
         "id" : "ba9f9c4fb47c1e65e8b8584a80b05c024c9abab5",
         "matched_substrings" : [
            {
               "length" : 3,
               "offset" : 0
            }
         ],
         "place_id" : "ChIJDQkpSv2tqDsRIBeczAHjVq0",
         "reference" : "CkQyAAAAEwbLyLxCGnxREVqs067O7MIHJuvr_gUTkBxWoGmZ4XX1xOi8x3krYrL5YelYwn-KdFCQwZw2Usrdr_14loGIfRIQc4CkJu7jq8t0K9VHO7viGxoUacOrVQYFfAS5x8s5bkQbZAQJVXQ",
         "terms" : [
            {
               "offset" : 0,
               "value" : "Bandipur"
            },
            {
               "offset" : 10,
               "value" : "Karnataka"
            },
            {
               "offset" : 21,
               "value" : "India"
            }
         ],
         "types" : [ "locality", "political", "geocode" ]
      },
      {
         "description" : "Bangkok Thailand",
         "id" : "56ef65d942d42054613887fd09cee596d5949359",
         "matched_substrings" : [
            {
               "length" : 3,
               "offset" : 0
            }
         ],
         "place_id" : "ChIJ82ENKDJgHTERIEjiXbIAAQE",
         "reference" : "CjQoAAAALKjoDM_fiySdIqLK1_B-lWKsH0CyTmaXChd-XYUs6hejfoflIwFfFvUcj12e9Ae3EhC4qhXFaJMMqAIy7-_TJvKKGhQQ7VifQF1Cpn8-bMXCceX0MaQa9A",
         "terms" : [
            {
               "offset" : 0,
               "value" : "Bangkok"
            },
            {
               "offset" : 8,
               "value" : "Thailand"
            }
         ],
         "types" : [ "locality", "political", "geocode" ]
      }
   ],
   "status" : "OK"
}

You’ll note that the description key has the place name which is sort of in the format “city, state, country” or “city, country”. The place_id data can be used to make Place Details Requests in which you can get the lat/long any several other details regarding the place.

Adding Place Autocomplete to the Android App

Adding Place Autocomplete to an Android app is fairly easy. All we have to do is issue search requests to the Place API over HTTP and parse the JSON/XML response to display the results in an AutoCompleteTextView. The requests issued are just like any other request from any type of client like a web browser or a server-side programming language.

Creating the Request Class

We’ll create a class called PlaceAPI that will send requests to the API over HTTP and then parse the response JSON to build a list of strings containing the description key which contains the city/state/country names (like “Bangalore, Karnataka, India”). Here’s the class code:

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
public class PlaceAPI {
 
    private static final String TAG = PlaceAPI.class.getSimpleName();
 
    private static final String PLACES_API_BASE = "https://maps.googleapis.com/maps/api/place";
    private static final String TYPE_AUTOCOMPLETE = "/autocomplete";
    private static final String OUT_JSON = "/json";
 
    private static final String API_KEY = "YOUR_API_KEY";
 
    public ArrayList<String> autocomplete (String input) {
        ArrayList<String> resultList = null;
 
        HttpURLConnection conn = null;
        StringBuilder jsonResults = new StringBuilder();
 
        try {
            StringBuilder sb = new StringBuilder(PLACES_API_BASE + TYPE_AUTOCOMPLETE + OUT_JSON);
            sb.append("?key=" + API_KEY);
            sb.append("&types=(cities)");
            sb.append("&input=" + URLEncoder.encode(input, "utf8"));
 
            URL url = new URL(sb.toString());
            conn = (HttpURLConnection) url.openConnection();
            InputStreamReader in = new InputStreamReader(conn.getInputStream());
 
            // Load the results into a StringBuilder
            int read;
            char[] buff = new char[1024];
            while ((read = in.read(buff)) != -1) {
                jsonResults.append(buff, 0, read);
            }
        } catch (MalformedURLException e) {
            Log.e(TAG, "Error processing Places API URL", e);
            return resultList;
        } catch (IOException e) {
            Log.e(TAG, "Error connecting to Places API", e);
            return resultList;
        } finally {
            if (conn != null) {
                conn.disconnect();
            }
        }
 
        try {
            // Log.d(TAG, jsonResults.toString());
 
            // Create a JSON object hierarchy from the results
            JSONObject jsonObj = new JSONObject(jsonResults.toString());
            JSONArray predsJsonArray = jsonObj.getJSONArray("predictions");
 
            // Extract the Place descriptions from the results
            resultList = new ArrayList<String>(predsJsonArray.length());
            for (int i = 0; i < predsJsonArray.length(); i++) {
                resultList.add(predsJsonArray.getJSONObject(i).getString("description"));
            }
        } catch (JSONException e) {
            Log.e(TAG, "Cannot process JSON results", e);
        }
 
        return resultList;
    }
}

Next, make sure your layout file (for instance res/layout/activity_main.xml) has an AutoCompleteTextView:

1
2
3
4
5
6
<!-- City and Country Selector -->
<AutoCompleteTextView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:id="@+id/autocomplete"
    android:hint="Type in your Location" />

Also create another layout file (res/layout/autocomplete_list_item.xml) that’ll hold the views representing each entry inside the AutoCompleteTextView:

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="10dp"
    android:id="@+id/autocompleteText" />

Pretty cool! Now your Activity/Fragment class must set the adapter for the AutoCompleteTextView:

1
2
AutoCompleteTextView autocompleteView = (AutoCompleteTextView) rootView.findViewById(R.id.autocomplete);
autocompleteView.setAdapter(new PlacesAutoCompleteAdapter(getActivity(), R.layout.autocomplete_list_item));

It’s time to code the PlacesAutoCompleteAdapter class now which should be subtype of ListAdapter (extends) and Filterable (implements), if you check the type of parameter specified for the setAdapter() method in the docs.

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
class PlacesAutoCompleteAdapter extends ArrayAdapter<String> implements Filterable {
 
    ArrayList<String> resultList;
 
    Context mContext;
    int mResource;
     
    PlaceAPI mPlaceAPI = new PlaceAPI();
 
    public PlacesAutoCompleteAdapter(Context context, int resource) {
        super(context, resource);
 
        mContext = context;
        mResource = resource;
    }
 
    @Override
    public int getCount() {
        // Last item will be the footer
        return resultList.size();
    }
 
    @Override
    public String getItem(int position) {
        return resultList.get(position);
    }
 
    @Override
    public Filter getFilter() {
        Filter filter = new Filter() {
            @Override
            protected FilterResults performFiltering(CharSequence constraint) {
                FilterResults filterResults = new FilterResults();
                if (constraint != null) {
                    resultList = mPlaceAPI.autocomplete(constraint.toString());
 
                    filterResults.values = resultList;
                    filterResults.count = resultList.size();
                }
 
                return filterResults;
            }
 
            @Override
            protected void publishResults(CharSequence constraint, FilterResults results) {
                if (results != null && results.count > 0) {
                    notifyDataSetChanged();
                }
                else {
                    notifyDataSetInvalidated();
                }
            }
        };
 
        return filter;
    }
}

As the user types something inside the AutoCompleteTextView, the filter() method on the Filter object returned by getFilter() will be called that’ll trigger performFiltering() (asynchronously in a background thread). This method calls the autocomplete() method on the `PlaceAPI` class which makes the HTTP calls to the Places API returning results for the string/text entered by the user. The API does a substring match against its database of places and returns a result containing predictions which are basically a list of the places found. The predictions are stored in an ArrayList which are then used to populate the AutoCompleteTextView internally by the getView() method of ArrayAdapter.

You should go through the AutoCompleteTextView source code if you feel like. Also I’ve written an article on the Filter class and Filterable interface before that you should consider reading to get a deep understanding of the filtering works in the piece of code shown above.

Getting the Selection Made

When an item from the AutoCompleteTextView’s list is selected, we can get the data associated with that list item, i.e., the selection made (from the ArrayList used as the data source by the adapter).

1
2
3
4
5
6
7
8
9
autocompleteView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
    @Override
    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
        // Get data associated with the specified position
        // in the list (AdapterView)
        String description = (String) parent.getItemAtPosition(position);
        Toast.makeText(getActivity(), description, Toast.LENGTH_SHORT).show();
    }
});

Show Powered by Google Logo

According to the Places API Policies, if you’re using the Place Autocomplete service without Google Maps, then a powered by Google logo has to be shown. Generally this logo is shown right at the end of the AutoCompleteTextView list items. The logos for various platforms are available in the policies page that you can download and use.

So how to show up the logo in the autocomplete list ? AutoCompleteTextView doesn’t have addHeaderView() and addFooterView()methods like the ListView ViewGroup. So for now, one way to achieve this is by adding an extra element to the ArrayList used as the data source by the adapter and then overriding the getView() method in the Adapter. In the getView() if the position is the last index (equals list.size() - 1) then inflate a layout which contains an ImageView whose src is set to the google logo’s drawable file.

In practise, this is how a quick modification to our adapter implementation would look like:

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
@Override
public View getView(int position, View convertView, ViewGroup parent) {
    View view;
 
    //if (convertView == null) {
        LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
 
        if (position != (resultList.size() - 1))
            view = inflater.inflate(R.layout.autocomplete_list_item, null);
        else
            view = inflater.inflate(R.layout.autocomplete_google_logo, null);
    //}
    //else {
    //    view = convertView;
    //}
 
    if (position != (resultList.size() - 1)) {
        TextView autocompleteTextView = (TextView) view.findViewById(R.id.autocompleteText);
        autocompleteTextView.setText(resultList.get(position));
    }
    else {
        ImageView imageView = (ImageView) view.findViewById(R.id.imageView);
        // not sure what to do <img draggable="false" class="emoji" alt="😀" src="https://s.w.org/images/core/emoji/72x72/1f600.png">
    }
 
    return view;
}

This looks really dirty and doesn’t make use of cache. You should implement the ViewHolder pattern for a better efficient implementation. Also a small line of code will have to be added in the performFiltering()method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected FilterResults performFiltering(CharSequence constraint) {
    FilterResults filterResults = new FilterResults();
    if (constraint != null) {
        resultList = mPlaceAPI.autocomplete(constraint.toString());
 
        // Footer
        resultList.add("footer");
 
        filterResults.values = resultList;
        filterResults.count = resultList.size();
    }
 
    return filterResults;
}

Notice the // Footer comment and the line next to it. This should be enough to abide by their logo requirement policies.

Setting Timeouts for Requests

In the current situation, when the user types something in the box, our code will make API requests to Places Autocomplete service with every character typed (or removed). This can quickly eat up our usage limit. So to reduce the total number of requests made, we’ve to set a timeout for each request as something is typed into the input box. What this means is that when the user is done typing wait for a short time like 500ms or 1000ms (1s) and then make API requests.

So initially I was a little stuck on how to do this. I thought the filtering logic will have to be modified. but then that is sort of not possible. Internally, AutoCompleteTextView attaches a TextWatcher to itself whose afterTextChanged() method keeps on calling the filter() method on the Filter object we supply. Now the autocomplete() method on PlaceAPI must not be called on the UI/Main thread (since it’s a Network operation) and performFiltering() already gets called on a background thread which must also return a FilterResults instantly. So I guess one way to modify the performFiltering() logic and making it work might be to create a new Handler object inside that (which’ll attach to the background HandlerThread created internally) and then post Runnables to it with a delay. Then return null or an empty FilterResults object which will then call publishResults where you pretty much do nothing. When the internal created handler is done executing, post it to a Handler on the main thread. But I think after waiting for 3 seconds or so, the HandlerThread will quit, so this can be a little messy.

Instead what I did was empty the filtering logic and add my own TextWatcher to achieve all of this. The TextWatcher would post the autocomplete operation to a HandlerThread that would do the job in a worker thread asynchronously. First we’ll initialize the HandlerThread and its associated Handler in the constructor.

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
private static String TAG = MainActivity.class.getSimpleName();
 
private PlacesAutoCompleteAdapter mAdapter;
 
HandlerThread mHandlerThread;
Handler mThreadHandler;
public ProfileFragment() {
    // Required empty public constructor
 
    if (mThreadHandler == null) {
        // Initialize and start the HandlerThread
        // which is basically a Thread with a Looper
        // attached (hence a MessageQueue)
        mHandlerThread = new HandlerThread(TAG, android.os.Process.THREAD_PRIORITY_BACKGROUND);
        mHandlerThread.start();
 
        // Initialize the Handler
        mThreadHandler = new Handler(mHandlerThread.getLooper()) {
            @Override
            public void handleMessage(Message msg) {
                if (msg.what == 1) {
                    ArrayList<String> results = mAdapter.resultList;
 
                    if (results != null && results.size() > 0) {
                        mAdapter.notifyDataSetChanged();
                    }
                    else {
                        mAdapter.notifyDataSetInvalidated();
                    }
                }
            }
        };
    }
 
}

Then add a custom text watcher:

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
autocompleteView.addTextChangedListener(new TextWatcher() {
    @Override
    public void beforeTextChanged(CharSequence s, int start, int count, int after) {
 
    }
 
    @Override
    public void onTextChanged(CharSequence s, int start, int before, int count) {
        final String value = s.toString();
 
        // Remove all callbacks and messages
        mThreadHandler.removeCallbacksAndMessages(null);
 
        // Now add a new one
        mThreadHandler.postDelayed(new Runnable() {
 
            @Override
            public void run() {
                // Background thread
 
                mAdapter.resultList = mAdapter.mPlaceAPI.autocomplete(value);
 
                // Footer
                if (mAdapter.resultList.size() > 0)
                    mAdapter.resultList.add("footer");
 
                // Post to Main Thread
                mThreadHandler.sendEmptyMessage(1);
            }
        }, 500);
    }
 
    @Override
    public void afterTextChanged(Editable s) {
        doAfterTextChanged();
    }
});

Then finally in onDestroy() we should quit the HandlerThread:

1
2
3
4
5
6
7
8
9
10
@Override
public void onDestroy() {
    super.onDestroy();
 
    // Get rid of our Place API Handlers
    if (mThreadHandler != null) {
        mThreadHandler.removeCallbacksAndMessages(null);
        mHandlerThread.quit();
    }
}

To learn more about HandlerThreads, you should read this article that I wrote sometime back.

Make sure the filtering logic, i.e., performFiltering() and publishResults()are empty. You can return null or an empty FilterResults object from performFiltering().

Now both the internal TextWatcher and the one you add will keep on executing. The internal one will try to filter but since the filtering logic is empty, nothing will happen. The custom one that we added will do the actual filtering by modifying the data source (ArrayList) of the Adapter and triggering the notifyDataSetChanged().

API Keys

There’s some information regarding where you should store your API Key at the end of this article that you should go through once.

Summary

The Google Place Autocomplete service makes it really simple to integrate a widget where the user can select his location that the app can store and later use for various purposes. If you think about it carefully, Google’s service is only being used to fetch a set of results in JSON format. So replacing that with your own API service will be fairly easy in terms of Android code, i.e., you’ll just need to make few changes in the PlaceAPIclass. This is the power of separation and abstraction. If you think you’ll surpass Google’s request limit or cannot afford them, then consider building your own service using some free/paid databases provided by various vendors online.

'android' 카테고리의 다른 글

구글 마커 클러스터링  (0) 2016.12.24
구글 마커 커스터마이징  (0) 2016.12.24
resource->arrays->2차배열 만들기  (0) 2016.12.24
SQLITE  (0) 2016.12.24
페이스북 로그인  (0) 2016.12.24
Comments