Some client apps make use of Turbolinks-Android to provide a native Android experience for their Rails web apps. This is fine until you try to use something like an HTML5 date field. When the user taps on the field they get… nothing. No datepicker, not even a keyboard, just a flashing cursor trapped in a forever-empty field.
This is a known issue with Turbolinks-Android. It appears that most HTML5 fields will work, but date/time and autofill will not.
What Turbolinks-Android gives us
One of the nice things Turbolinks-Android provides us is the ability to send messages to the app via javascript.
On the Rails-side, we can just add this snippet:
if (this.JsNativeDatePicker) {
$(document).on('click', '[type~=date]', function(event) {
JsNativeDatePicker.showDatePicker(event.target.id, event.target.value);
return false;
});
}
For the most part, this is fairly clear. We’re adding an on-click handler to date fields that fires off the id and value of the field to JsNativeDatePicker.
But what about this JsNativeDatePicker? Where did that come from?
Turbolinks-Android uses a @JavaScriptInterface
annotation for public methods that are then magically available for us to call from Javascript. So in our JsNativeDatePicker.java
we have something like this:
public class JsNativeDatePicker
...
@JavascriptInterface
public void showDatePicker (String id, String value) {
elementId = id;
Calendar cal = Calendar.getInstance();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH);
try {
cal.setTime(sdf.parse(value));
} catch (ParseException p) {
cal = null;
}
DialogFragment newFragment = new DatePickerFragment(this, cal);
newFragment.show(activity.getFragmentManager(), "datePicker");
}
Here we are taking the id and value passed in from the Javascript call and using it to create a new datepicker.
The DatepickerFragment
To display a DatePicker, we are wrapping it in a new DatePickerFragment. The code for this is pretty straight-forward, based on the documentation at https://developer.android.com/guide/topics/ui/controls/pickers
public class DatePickerFragment extends DialogFragment {
private DatePickerDialog.OnDateSetListener listener;
private Calendar calendar;
public DatePickerFragment(DatePickerDialog.OnDateSetListener listener, Calendar calendar) {
this.listener = listener;
if (calendar == null) {
calendar = Calendar.getInstance();
}
this.calendar = calendar;
}
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
int year = calendar.get(Calendar.YEAR);
int month = calendar.get(Calendar.MONTH);
int day = calendar.get(Calendar.DAY_OF_MONTH);
// Create a new instance of DatePickerDialog and return it
return new DatePickerDialog(getActivity(), listener, year, month, day);
}
}
Fairly self-explanatory. We take an OnDateSetListener
to listen for updates when the user selects a date, and a Calendar
of the current value to use (defaulting to today via Calendar.getInstance()
).
Updating the value
Here the JsNativeDatePicker
class is also going to function as our OnDateSetListener
to receive updates when the user selects a value.
public class JsNativeDatePicker
implements DatePickerDialog.OnDateSetListener {
public void onDateSet(DatePicker view, int year, int month, int day) {
webView.evaluateJavascript(getUpdateJavascript(elementId, year, month, day),
new ValueCallback<String>() {
@Override
public void onReceiveValue(String s) {
}
});
}
private String getUpdateJavascript(String id, int year, int month, int day) {
return String.format(Locale.ENGLISH, "(function() {" +
"var input = document.getElementById('%s');" +
"input.value = '%d-%02d-%02d';" +
"})();",
id, year, month+1, day);
}
}
When the user has picked a date, our onDateSet
method is hit. Now we have the value, but we still need to update the html field. To do this we inject some javascript straight into the WebView
. Note that the month is in the range 0..11
for compatibility with Calendar.MONTH
, so we simply +1 it here.
Hooking it up
Now that the pieces are in place we just need to register our shiny new datepicker so it’s available from Javascript on the page.
When setting up Turbolinks (in my case this was in TurbolinksHelper.java#setupTurbolinks
) simply add this line:
// WebView webView = TurbolinksSession.getDefault(context).getWebView();
webView.addJavascriptInterface(new JsNativeDatePicker(activity, webView), "JsNativeDatePicker");
addJavascriptInterface
will take any class as a parameter, then any public methods with the @JavaScriptInterface
annotation will be available to call. The second parameter ("JsNativeDatePicker"
here) is what you will call from javascript - in our case JsNativeDatePicker.<annotated method>
. I’ve used the class name here for clarity, but you can actually use any name you want as long as it is unique.
Summary
Turbolinks native wrappers provide some very handy functionality, and allow you provide some mobile-native elements without having to duplicate business logic in both the native app and the backend - you just have to be aware of the rough edges where you have to provide the Rails-to-native bridging manually.