This summer I have been adding some features to Nurse AMIE, an Android app proposed, developed, and tested by Dr. Kathryn Schmitz at the Penn State Cancer Institute.
In the last post about this app, I talked to you about the role of empathy in mobile apps for care management. We discussed the user interface design decisions that went into the weekly survey form, and why they mattered. In this post, I’ll talk about the technical execution of the form.
To help you understand my decisions about this form, you need to see the questions:
- What was the severity of your decreased appetite at its worst?
- How much did decreased appetite interfere with your usual or daily activities?
- How often did you have nausea?
- What was the severity of your nausea at its worst?
- How often did you have vomiting?
- What was the severity of your vomiting at its worst?
- What was the severity of your constipation at its worst?
- How often did you have loose or watery stools (diarrhea/diarrhoea)?
- How often did you have pain in the abdomen (belly area)?
- What was the severity of your pain in the abdomen (belly area) at its worst?
- How much did pain in the abdomen (belly area) interfere with your usual or daily activities?
- What was the severity of your shortness of breath at its worst?
- How much did your shortness of breath interfere with your usual or daily activities?
- What was the severity of your cough at its worst?
- How much did cough interfere with your usual or daily activities?
- What was the severity of your numbness or tingling in your hands or feet at its worst?
- How much did numbness or tingling in your hands or feet interfere with your usual or daily activities?
- How often did you have pain?
- What was the severity of your pain at its worst?
- How much did pain interfere with your usual or daily activities?
- What was the severity of your fatigue, tiredness, or lack of energy at its worst?
- How much did fatigue, tiredness, or lack of energy interfere with your usual or daily activities?
- What was the severity of your pain or burning with urination at its worst?
- How often did you have hot flashes/flushes?
- What was the severity of your hot flashes/flushes at their worst?
Each question had a set of radio button responses, like so:
Many of these questions are similar, but not exactly the same. I’ll show you where I placed my seams, and you can choose whether you agree with my choices or not.
Among these 25 questions, I notice that each one takes two categories.
- A symptom. Each question asks about one of thirteen symptoms: decreased appetite, nausea, vomiting, constipation, diarrhea, abdominal pain, shortness of breath, cough, numbness/tingling, general pain, fatigue, urination pain, and hot flashes/flushes.
- A modifier. Each question asks about one of three modifiers: frequency, severity, and interference in daily life. Some symptoms have questions for all three of these modifiers, but most only have questions for one or two.
We’ll want our view to divide up the survey into sections for each symptom. The real potential for duplication in this code, though, lies in the radio buttons. We have 25 questions that fall into three modifier categories, and for each of those modifier categories, the radio buttons have the same labels:
- frequency: never, rarely, occasionally, frequently, almost constantly
- severity: none, mild, moderate, severe, very severe
- interference: not at all, a little bit, somewhat, quite a bit, very much
Reusing Views in Android
Android allows us to extract our own layout XML files and then include them in other layout files like this:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
… | |
<include android:id="@+id/appetite_decrease_severity_response" | |
android:layout_width="match_parent" | |
android:layout_height="match_parent" | |
layout="@layout/severity_question"/> | |
… |
In this case, the layout in fragment_weekly_survey.xml
includes a custom view that lives in an xml file called severity_question.xml
.
There are some weird rules about how these custom layouts work. First of all, I can assign an id to my custom view (see line 3), but it only gets registered if I also assign the layout_width and layout_height (see lines 4 and 5). Also, if I want to be able to assign an id, then my custom layout has to contain a single child, and that child has to be of the layout type, like so:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?xml version="1.0" encoding="utf-8"?> | |
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | |
android:layout_width="match_parent" | |
android:layout_height="match_parent" | |
> | |
<RadioGroup | |
android:id="@+id/radio_group" | |
android:layout_width="match_parent" | |
android:layout_height="wrap_content" | |
android:orientation="horizontal" | |
android:paddingBottom="10dp" | |
android:paddingTop="10dp"> | |
<RadioButton | |
android:id="@+id/severity_none" | |
android:paddingRight="8dp" | |
android:textSize="20sp" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:text="@string/none" /> | |
<RadioButton | |
android:id="@+id/severity_mild" | |
android:paddingRight="8dp" | |
android:textSize="20sp" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:text="@string/mild" /> | |
<RadioButton | |
android:id="@+id/severity_moderate" | |
android:paddingRight="8dp" | |
android:textSize="20sp" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:text="@string/moderate" /> | |
<RadioButton | |
android:id="@+id/severity_severe" | |
android:paddingRight="8dp" | |
android:textSize="20sp" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:text="@string/severe" /> | |
<RadioButton | |
android:id="@+id/severity_very_severe" | |
android:paddingRight="8dp" | |
android:textSize="20sp" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:text="@string/very_severe" /> | |
</RadioGroup> | |
</LinearLayout> |
Android developers the world over cringe at this because fragment_weekly_survey.xml
already has a base layout, meaning that this custom view nests a layout inside a layout, and the deeper you go doing that, the longer it takes Android to render the screen. (Luckily, just one layer doesn’t usually produce a noticeable difference). Moreover, in this case, the weekly survey fragment’s base layout is already a LinearLayout that stacks views up on top of each other without overlapping them—so the additional LinearLayout in the custom view doesn’t provide any new information about how the views should be arranged.
Android does have a way around this, called the <merge>
tag. I could put the custom view inside a <merge>
tag, and this will tell Android to drop all the views inside it wholesale into the parent layout, without requiring their own layout. I tried it on this app, but it turns out <merge>
tags do not play nice with assigning an id to the custom views, and I need to do that to be able to tell which radio group is answering which question in the fragment.
View Binding
Here’s the onResume()
method of my Fragment, where you can see the view binding:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class WeeklySurveyFragment extends SmartFragment implements WeeklySurveyView, OnWebserviceResponse { | |
… | |
@Override | |
public void onResume() { | |
super.onResume(); | |
appetiteDecreaseSeverityResponse = getView().findViewById(R.id.appetite_decrease_severity_response); | |
appetiteDecreaseInterferenceResponse = getView().findViewById(R.id.appetite_decrease_interference_response); | |
nauseaFrequencyResponse = getView().findViewById(R.id.nausea_frequency_response); | |
nauseaSeverityResponse = getView().findViewById(R.id.nausea_severity_response); | |
vomitingFrequencyResponse = getView().findViewById(R.id.vomiting_frequency_response); | |
vomitingSeverityResponse = getView().findViewById(R.id.vomiting_severity_response); | |
constipationSeverityResponse = getView().findViewById(R.id.constipation_severity_response); | |
diarrheaFrequencyResponse = getView().findViewById(R.id.diarrhea_frequency_response); | |
abdominalPainFrequencyResponse = getView().findViewById(R.id.abdominal_pain_frequency_response); | |
abdominalPainSeverityResponse = getView().findViewById(R.id.abdominal_pain_severity_response); | |
abdominalPainInterferenceResponse = getView().findViewById(R.id.abdominal_pain_interference_response); | |
shortnessOfBreathSeverityResponse = getView().findViewById(R.id.shortness_of_breath_severity_response); | |
shortnessOfBreathInterferenceResponse = getView().findViewById(R.id.shortness_of_breath_interference_response); | |
coughSeverityResponse = getView().findViewById(R.id.cough_severity_response); | |
coughInterferenceResponse = getView().findViewById(R.id.cough_interference_response); | |
numbnessSeverityResponse = getView().findViewById(R.id.numbness_severity_response); | |
numbnessInterferenceResponse = getView().findViewById(R.id.numbness_interference_response); | |
painFrequencyResponse = getView().findViewById(R.id.pain_frequency_response); | |
painSeverityResponse = getView().findViewById(R.id.pain_severity_response); | |
painInterferenceResponse = getView().findViewById(R.id.pain_interference_response); | |
fatigueSeverityResponse = getView().findViewById(R.id.fatigue_severity_response); | |
fatigueInterferenceResponse = getView().findViewById(R.id.fatigue_interference_response); | |
urinationDiscomfortSeverityResponse = getView().findViewById(R.id.urination_discomfort_severity_response); | |
hotFlashFrequencyResponse = getView().findViewById(R.id.hot_flash_frequency_response); | |
hotFlashSeverityResponse = getView().findViewById(R.id.hot_flash_severity_response); | |
final OnWebserviceResponse apiCallRecipient = this; | |
surveyResponses = new HashMap<>(); | |
surveyResponses.put("appetiteDecreaseSeverityResponse", appetiteDecreaseSeverityResponse); | |
surveyResponses.put("appetiteDecreaseInterferenceResponse", appetiteDecreaseInterferenceResponse); | |
surveyResponses.put("nauseaFrequencyResponse", nauseaFrequencyResponse); | |
surveyResponses.put("nauseaSeverityResponse", nauseaSeverityResponse); | |
surveyResponses.put("vomitingFrequencyResponse", vomitingFrequencyResponse); | |
surveyResponses.put("vomitingSeverityResponse", vomitingSeverityResponse); | |
surveyResponses.put("constipationSeverityResponse", constipationSeverityResponse); | |
surveyResponses.put("diarrheaFrequencyResponse", diarrheaFrequencyResponse); | |
surveyResponses.put("abdominalPainFrequencyResponse", abdominalPainFrequencyResponse); | |
surveyResponses.put("abdominalPainSeverityResponse", abdominalPainSeverityResponse); | |
surveyResponses.put("abdominalPainInterferenceResponse", abdominalPainInterferenceResponse); | |
surveyResponses.put("shortnessOfBreathSeverityResponse", shortnessOfBreathSeverityResponse); | |
surveyResponses.put("shortnessOfBreathInterferenceResponse", shortnessOfBreathInterferenceResponse); | |
surveyResponses.put("coughSeverityResponse", coughSeverityResponse); | |
surveyResponses.put("coughInterferenceResponse", coughInterferenceResponse); | |
surveyResponses.put("numbnessSeverityResponse", numbnessSeverityResponse); | |
surveyResponses.put("numbnessInterferenceResponse", numbnessInterferenceResponse); | |
surveyResponses.put("painFrequencyResponse", painFrequencyResponse); | |
surveyResponses.put("painSeverityResponse", painSeverityResponse); | |
surveyResponses.put("painInterferenceResponse", painInterferenceResponse); | |
surveyResponses.put("fatigueSeverityResponse", fatigueSeverityResponse); | |
surveyResponses.put("fatigueInterferenceResponse", fatigueInterferenceResponse); | |
surveyResponses.put("urinationDiscomfortSeverityResponse", urinationDiscomfortSeverityResponse); | |
surveyResponses.put("hotFlashFrequencyResponse", hotFlashFrequencyResponse); | |
surveyResponses.put("hotFlashSeverityResponse", hotFlashSeverityResponse); | |
submitSurveyButton.setOnClickListener(new View.OnClickListener() { | |
@Override | |
public void onClick(View view) { | |
isFormValid = true; | |
WeeklySurveyDao weeklySurveyDao = ((AmieApplication) getActivity().getApplication()).getDaoSession().getWeeklySurveyDao(); | |
WeeklySurvey weeklySurvey = new WeeklySurvey(); | |
weeklySurvey.setDate(new Date()); | |
Iterator hmIterator = surveyResponses.entrySet().iterator(); | |
while (hmIterator.hasNext()) { | |
Map.Entry mapElement = (Map.Entry) hmIterator.next(); | |
String response = extractResponseFor((View) mapElement.getValue()); | |
weeklySurvey.set((String) mapElement.getKey(), response); | |
} | |
if (isFormValid) { | |
sendSurveyResults(weeklySurvey, apiCallRecipient); | |
DailyQuestionFragment dailyQuestionFragment = DailyQuestionFragment.getInstance(); | |
originPresenter.getFragmentTransaction(dailyQuestionFragment, true).commit(); | |
} else { | |
weeklySurveyDao.insert(weeklySurvey); | |
showMessage("", "Oops! Please answer all the questions.", new SmartSuperFragment.OnMessageClick() { | |
@Override | |
public void onMessageClick(DialogInterface dialog) { | |
//dismiss | |
} | |
}); | |
} | |
} | |
}); | |
} | |
} |
Yikes. It’s long and repetitive.
Theoretically we could do some fancy meta-programming here with string evaluation of the view ids to collect all the question responses, but I didn’t think that length alone was sufficient justification to do something that clever instead of leaving it clear what’s happening. It’s also worth noting that lines 7-31 would be unnecessary with view binding as long as the variables had the same names as the view IDs (or even, it turns out, camelCase versions of equivalent snake_case_view_ids). Unfortunately, view binding does not play nice with the “include” syntax, and in the process of trying it on this code base I learned that the generated view binding classes look like this:
So they’re not producing a more efficient pattern; they’re just generating boilerplate that devs were writing manually*. That’s minimally helpful. It would edge over into helpful if it worked seamlessly, but it doesn’t:
What’s happening here, I believe, is that the generated class needs to be explicitly casting those view assignments with findViewById<ViewType>
rather than straight findViewById
. At this point, I’d rather spend three minutes cranking boilerplate that I know is going to work than patch generated code.
*View binding does add null safety, which findViewById
lacks. This is, theoretically, its big selling point. Guess what, I found the special sauce, and it’s this:
A @NonNull annotation. This is still not, to me, edging out functioning boilerplate by very much.
Moving on.
Lines 35-60 tie each response view to a name that we use to refer to the question that the response answers. Then on lines 72-76, during form submission, we map each of those key-value pairs to a new key-value pair, with the same key as the original pair, pointing to the string label of the radio button that is filled in for that question. There will be no nulls here: instead of introducing logic to handle null, we set a default value of "UNKNOWN"
for unanswered questions. As it so happens, we also insist that all questions be answered before letting someone submit this form, but even if we didn’t do that, our app would not crash over a null pointer exception here.
This is the method that extracts the responses, as strings, from our custom response views (and sets the form to valid or invalid based on whether all questions have an answer):
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
… | |
private String extractResponseFor(View view) { | |
RadioGroup options = (RadioGroup) view.findViewById(R.id.radio_group); | |
int selection = options.getCheckedRadioButtonId(); | |
if (selection == –1) { | |
options.setBackgroundColor(getActivity().getResources().getColor(R.color.form_validation)); | |
isFormValid = false; | |
return getString(R.string.unknown); | |
} else { | |
options.setBackgroundColor(0); | |
RadioButton button = (RadioButton) view.findViewById(selection); | |
return button.getText().toString(); | |
} | |
} | |
… |
This code also reveals the purpose of the surveyResponses
hash that we initialize in onResume()
: on lines 7 and 11, we manipulate the background color of the view in that hash’s value to help the person easily see which responses still need to be filled in when they try to submit without completing the whole form.
To dupe…or not to dupe?
I did not extricate custom views for the collection of section headers, or for the questions themselves. There is some duplicate wording in those questions that therefore propagates to the code. I’m okay with this. In my experience, DRY-ing up text (and code, for that matter), with subtle differences is a fun thought experiment that doesn’t fare as well on a cost-benefit analysis as we programmers tend to assume it does.
So the XML that renders the hot flashes/flushes question that you see above lives in the fragment_weekly_survey.xml
file, and it looks like this:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
… | |
<TextView | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:paddingTop="24dp" | |
android:paddingBottom="20dp" | |
android:textColor="@color/black" | |
android:text="@string/hot_flashes_flushes_in_the_last_7_days" | |
android:textStyle="bold" | |
android:textSize="26sp" /> | |
<TextView | |
android:id="@+id/hot_flash_frequency_question" | |
android:textColor="#000000" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:paddingTop="24dp" | |
android:text="@string/in_the_last_7_days_how_often_did_you_have_hot_flashes_flushes" | |
android:textSize="24sp" /> | |
<include android:id="@+id/hot_flash_frequency_response" | |
android:layout_width="match_parent" | |
android:layout_height="match_parent" | |
layout="@layout/frequency_question"/> | |
<TextView | |
android:id="@+id/hot_flash_severity_question" | |
android:textColor="@color/black" | |
android:layout_width="wrap_content" | |
android:layout_height="wrap_content" | |
android:paddingTop="24dp" | |
android:text="@string/in_the_last_7_days_what_was_the_severity_of_your_hot_flashes_flushes_at_their_worst" | |
android:textSize="24sp" /> | |
<include android:id="@+id/hot_flash_severity_response" | |
android:layout_width="match_parent" | |
android:layout_height="match_parent" | |
layout="@layout/severity_question"/> | |
<View | |
android:layout_width="match_parent" | |
android:layout_height="1dp" | |
android:background="@color/colorPrimary" | |
android:paddingBottom="20dp" | |
android:paddingTop="20dp"/> | |
… |
I could theoretically use data binding to consolidate common view patterns a little more. I struggled with this for a while, but I could not find a solution to, for example, make the TextViews above an included question_view and pass in a text attribute.
I’d love a better way to label and handle the responses such that adding and removing questions requires changes in fewer places. That’s my personal aesthetic preference, though: I don’t have any evidence so far that we’ll need to do that.
And so…ta-da! Our view. In the next post on this application, I’ll talk about how we persist this weekly survey data to a local database.
If you liked this piece, you might also like:
The three-part series on teaching a programming course
The behind the scenes series
The leveling-up series on advancing your own skills (which will soon be an audiobook read by yours truly. Preorder here).
Thanks for this (and all your other) posts from a long-time lurker but infrequent commenter 🙂
“In my experience, DRY-ing up text (and code, for that matter), with subtle differences is a fun thought experiment that doesn’t fare as well on a cost-benefit analysis as we programmers tend to assume it does.”
^Definitely my gradually-learned opinion too! I’ve learned to avoid DRYing out new views even when the number of cases is initially very small because it is inevitable that 6 months from now that view will have 2^10 different theoretical configurations (of which < 10 are actually in use) due to bolted-on booleans 😀
Side note, as an Android folk, I wasn't quite sure what you were referring to requiring API 29+? AFAIK both data binding (older, essentially deprecated) and ViewBinding (newer, effectively replacing databinding) can be used on pretty much any API level. Some links that might help clarify: https://developer.android.com/topic/libraries/data-binding/start (API ≥ 14) and https://developer.android.com/topic/libraries/view-binding#setup (no min API, just a min Android built tools version, I think?)
Ah, you’re right! I should have checked my math: I wrote this piece a while after I wrote the code, and in this case I remembered that something didn’t work, but I seem to have mis-guessed as to why 😅
In this particular case, with data binding, I think the actual problem was that I couldn’t get, for example, this type of thing to work with my custom, included layouts:
Which was a shame, because then I could have made the whole question-and-response unit its own layout and included THAT in the fragment rather than the question and response views directly (to your point, I am wary about removing duplication for sure, but the 25 questions do all have the same format and so this case, if I could manage to pull it off within my timebox, I think I would have). I’ll update the piece to reflect that.
ViewBinding: it’s interesting that you mention that. Your link suggests that it would take some additional setup (on newer apps that I generate, I guess it’s enabled by default), but that maybe it would work for this. I might have to give this a try and see if I can eliminate that wall of “findViewById” in “onResume()”. Will report back.
Thank you!!
Reporting back! View binding, like data binding, did not play nice with include 🙁 in trying it, though, I learned a little bit about how the generated classes work and included that in this piece, so your commentary still resulted in a more informative piece! Thank you.
Oh nice, glad to got to experiment with it! are just tricky to bind well, I guess!
Edit: should read “Oh nice, glad you got to experiment with it! includes are just tricky to bind well, I guess!” (typo + html escaped :P)