Skip to content

Latest commit

 

History

History
155 lines (117 loc) · 7.64 KB

WIDGETS.md

File metadata and controls

155 lines (117 loc) · 7.64 KB

Question Widgets

Collect forms are defined using XForms which in the most simple sense are just a list of questions (which can be of various types). To render the form and let the enumerator fill it out Collect needs to be able to deal with each of these different question types. To do this Collect has a series of different QuestionWidget implementations - usually one for each type of question. The exact mapping between question types and widgets happens in WidgetFactory.

Implementing widgets

The TriggerWiget will be used here as an example of how to implement a widget. The TriggerWidget represents the acknowledge question type (its docs are here).

public class TriggerWidget extends QuestionWidget {

    private AppCompatCheckBox triggerButton;

    public TriggerWidget(Context context, QuestionDetails prompt) {
        super(context, prompt);
        render();
    }

    @Override
    protected View onCreateAnswerView(Context context, FormEntryPrompt prompt, int answerTextSize, int controlTextSize) {
        ViewGroup answerView = (ViewGroup) LayoutInflater.from(context).inflate(R.layout.trigger_widget_answer, null);

        triggerButton = answerView.findViewById(R.id.check_box);
        triggerButton.setTextSize(TypedValue.COMPLEX_UNIT_DIP, answerTextSize);
        triggerButton.setEnabled(!prompt.isReadOnly());
        triggerButton.setChecked(prompt.getAnswerText().equals("OK"));
        triggerButton.setOnCheckedChangeListener((buttonView, isChecked) -> widgetValueChanged());

        return answerView;
    }
    
    @Override
    public IAnswerData getAnswer() {
        return triggerButton.isChecked() ? new StringData(OK_TEXT) : null;
    }

    @Override
    public void clearAnswer() {
        triggerButton.setChecked(false);
        widgetValueChanged();
    }

    @Override
    public void setOnLongClickListener(OnLongClickListener l) {
        triggerButton.setOnLongClickListener(l);
    }

    @Override
    public void cancelLongPress() {
        super.cancelLongPress();
        triggerButton.cancelLongPress();
    }
}

To create your own widget your class needs to override several methods:

  • onCreateAnswerView - Returns the View object that represents the interface for answering the question. This will be rendered underneath the question's label, hint and guidance_hint. This method is passed the question itself (as a FormEntryPrompt) which will often be needed in rendering the widget. It is also passed the size to be used for question text and controls (like buttons).
  • getAnswer - Returns the current answer for the question. Can be null if the question has not been answered yet.
  • clearAnswer - Called when the answer for this question needs to be cleared for some reason. The implementation of this method should reset the UI of the widget.
  • setOnLongClickListener - Used to make sure clickable views in the widget work with the long click feature (shows the "Edit Prompt" menu). The passed listener should be set as the long click listener on clickable views in the widget.
  • cancelLongPress - As above this is used to make sure long click features work. The cancelLongPress call should simply be forwarded to clickable views in the widget.

As you'll see from the example you need to call widgetValueChanged whenever the answer is changed in someway. This will make sure that any listeners attached to the widget will be called. render must be called in your widget's constructor to signal that it's safe to call onCreateAnswerView and show the widget on screen.

Handling rotation/configuration changes

When the device is rotated during form entry, the on screen answers will be saved. Then, when it is is recreated, the Widget will be passed the correct answers in with FormEntryPrompt meaning no extra work is required to handle configuration changes. If a widget's answer layout contains any editable views (like an EditText for example) they should include android:saveEnabled="false" to prevent Android from trying to load previous values in.

Testing widgets

Widgets should have the majority of their behavior driven out by tests that treat them as an individual component. This means higher level feature tests for Collect won't have to be as concerned around the many types of question that exist and can be more focused on form entry, form management, settings etc as a whole.

You can use Robolectric to write tests for widgets without having to run them on device or manually mock out the Android SDK:

@RunWith(AndroidJUnit4.class)
public class TriggerWidgetTest {

    @Test
    public void getAnswer_whenPromptAnswerDoesNotHaveAnswer_returnsNull() {
        assertThat(createWidget(promptWithAnswer(null)).getAnswer(), nullValue());
    }

    @Test
    public void getAnswer_whenPromptHasAnswer_returnsAnswer() {
        TriggerWidget widget = createWidget(promptWithAnswer(new StringData("OK")));
        assertThat(widget.getAnswer().getDisplayText(), equalTo("OK"));
    }

    @Test
    public void clearAnswer_clearsWidgetAnswer() {
        TriggerWidget widget = createWidget(promptWithAnswer(new StringData("OK")));

        widget.clearAnswer();
        assertThat(widget.getAnswer(), nullValue());
    }

    @Test
    public void clearAnswer_callsValueChangeListeners() {
        TriggerWidget widget = createWidget(promptWithAnswer(null));
        WidgetValueChangedListener valueChangedListener = mockValueChangedListener(widget);

        widget.clearAnswer();
        verify(valueChangedListener).widgetValueChanged(widget);
    }

    @Test
    public void usingReadOnlyOption_makesAllClickableElementsDisabled() {
        TriggerWidget widget = createWidget(promptWithReadOnly());
        assertThat(widget.getCheckBox().getVisibility(), equalTo(View.VISIBLE));
        assertThat(widget.getCheckBox().isEnabled(), equalTo(Boolean.FALSE));
    }

    @Test
    public void whenPromptAnswerDoesNotHaveAnswer_checkboxIsUnchecked() {
        TriggerWidget widget = createWidget(promptWithAnswer(null));
        assertThat(widget.getCheckBox().isChecked(), equalTo(false));
    }

    @Test
    public void whenPromptHasAnswer_checkboxIsChecked() {
        TriggerWidget widget = createWidget(promptWithAnswer(new StringData("OK")));
        assertThat(widget.getCheckBox().isChecked(), equalTo(true));
    }

    @Test
    public void checkingCheckbox_setsAnswer() {
        TriggerWidget widget = createWidget(promptWithAnswer(null));
        CheckBox triggerButton = widget.getCheckBox();

        triggerButton.setChecked(true);
        assertThat(widget.getAnswer().getDisplayText(), equalTo("OK"));

        triggerButton.setChecked(false);
        assertThat(widget.getAnswer(), nullValue());
    }

    @Test
    public void checkingCheckbox_callsValueChangeListeners() {
        TriggerWidget widget = createWidget(promptWithAnswer(null));
        WidgetValueChangedListener valueChangedListener = mockValueChangedListener(widget);
        CheckBox triggerButton = widget.getCheckBox();

        triggerButton.setChecked(true);
        verify(valueChangedListener).widgetValueChanged(widget);
    }

    private TriggerWidget createWidget(FormEntryPrompt prompt) {
        return new TriggerWidget(widgetTestActivity(), new QuestionDetails(prompt, "formAnalyticsID"));
    }
}

This example makes sure that the widget interface methods (getAnswer, clearAnswer etc) all behave as expected and also checks that the widget looks and behaves correctly. Widget test helpers make these tests easier to write and can be in QuestionWidgetHelpers.