Cacomania: Build a Android GUI dynamically

Cacomania

Build a Android GUI dynamically

Guido Krömer - 10. April 2013 - Tags: ,

After buying the book: OCA Java SE 7 Progammer 1 Study Guide: Exam 1Z0-803 for learning some Java I got the idea writing a small multiple choice test Android App which uses the questions which has been delivered with the book CD. This is a typical win/win I retrieve an app for learning some test question on my tablet and I write some Java code, although Android does not support Java 7.

One main idea behind the App is loading a file, containing some JSON data, somewhere from the Internet which contains all the test questions. This gives the users the option to generate their own multiple choice tests. Since the tests can be loaded from somewhere out of the Internet the part where the UI displays the question as group of radio buttons or check boxes has to be generated dynamically.

Before I started programming the App I think, even if it's a small private project, some kind of plan is a good idea.

If you take a look at the diagram below you will notice several activites for selecting a test, downloading a new test or watching the result of the taken test, all of them have a static layout with exception of the activity for taking the selected test. The arrows shows which button is calling which activity.

Android workflow between different activities.

The BaseActivity

All activities inherit from the abstract BaseActivity class which contains some helper methods for easier retrieving a Button or creating a toast, for example. So don not get surprised if you find something like this in my code: getButton(R.id.buttonDownloadTest).setEnabled(false).

public abstract class BaseActivity extends FragmentActivity {
    ...
    protected Button getButton(int id) {
        return (Button) findViewById(id);
    }

    ...

    protected void showToastLong(int id) {
        showToastLong(getString(id));
    }

    protected void showToastLong(CharSequence text) {
        showToast(text, Toast.LENGTH_LONG);
    }

    ...

    private void showToast(CharSequence text, int duration) {
        Context context = getApplicationContext();
        Toast.makeText(context, text, duration).show();
    }
}

Downloading a test file

A little problem I ran into during development was that network communication has been permitted from the main thread in newer Android versions. Downloading a test file in the simulator with version 2.2 was working perfectly, but testing the App on a Nexus 7 which runs 4.2 lead to a NetworkOnMainThreadException.

The bad news are that downloading a test file, even if it just some kilobyte large, has to be done in an extra thread. The good news are, the ADK has a decent way for handling this problem, which is called AsyncTask. All I need was an inner class extending from the AsyncTask class and overriding two method stubs. The first one is doInBackground which performs the task and the second one is onPostExecute which gets called after doInBackground has terminated. There is a third one onProgressUpdate which could be used for updating the progress in the UI, but I am not using the method at the moment. Starting the task in background task is really simple: new Download().execute(url);. Interacting the UI is not allowed from doInBackground but onPostExecute or onProgressUpdate are allowed to interact with the UI. For determining if the download was successfully the doInBackground method returns a boolean value, as defined during extending from the AsyncTask base class. If it was successful the activity can be closed by calling finish().

public class DownloadTest extends BaseActivity {
    ...
    /**
     * Async task for downloading a file.
     * 
     */
    private class Download extends AsyncTask<URL, Void, Boolean> {
        @Override
        protected Boolean doInBackground(URL... urls) {
            try {
                String name = getBaseName(urls[0].getFile());

                InputStream is = urls[0].openStream();
                OutputStream os = openFileOutput(name, Context.MODE_PRIVATE);
                int c;
                while ((c = is.read()) != -1) {
                    os.write(c);
                }
                is.close();
                os.close();
            } catch (Exception e) {
                Log.v("OpenMCT", e.getMessage());
                return false;
            }
            return true;
        }

        @Override
        protected void onPostExecute(Boolean done) {
            if (done) {
                showToastLong(R.string.download_done);
                finish();
            } else {
                showToastLong(R.string.download_error);
                getButton(R.id.buttonDownloadTest).setEnabled(true);
                getEditText(R.id.editTextUrl).setEnabled(true);
                getProgressBar(R.id.progressBarDownload).setVisibility(
                        View.INVISIBLE);
            }
        }
    }
    ...
    /**
     * This listener start the async downlading task and disables some gui
     * elements.
     */
    View.OnClickListener onButtonDownloadClick = new OnClickListener() {
        @Override
        public void onClick(View v) {
            URL url;
            TextView textViewUrl = getEditText(R.id.editTextUrl);
            try {
                showToastLong(R.string.download_start);
                url = new URL(textViewUrl.getText().toString());
                getButton(R.id.buttonDownloadTest).setEnabled(false);
                textViewUrl.setEnabled(false);
                getProgressBar(R.id.progressBarDownload).setVisibility(
                        View.VISIBLE);
                new Download().execute(url);
            } catch (MalformedURLException e) {
                showToastLong(R.string.download_error_url_format);
            }
        }
    };
    ...
}

Android Download a OpenMCT file
The DownloadTest activity in action, after clicking the download button.

The file format

The file format is as mentioned before a simple JSON which consists out of some basic informations like a title and description text and an array of question objects, the question object has some mandatory fields like title, text, options and rightAnswers, hint and answerDescription are optional. The field options contain an array of string, one for each answer option, the rightAnswers array stores a boolean value for each option determining whether the option is correct. The example below show a simple two question MCT:

{
    "title": "My cool MCT",
    "description": "...",
    "questions": [{
            "title": "Question one",
            "text": "Schrödinger's cat is:",
            "options": ["Dead", "Alive"],
            "rightAnswers": [true, true],
            "answerDescription": "A description text which explains the right answers (optional)"
        },{
            "title": "Question two",
            "text": "What's the difference between a duck?",
            "options": ["What?", "No", "Yes", "WTF?"],
            "rightAnswers": [false, false, true, false],
            "hint": "Some hint text to help the user (optional)"
        },
    ]
}

You may take a look at this tiny class diagram below, showing the Test and Question classes which are storing the MCT data in the application. The file names are identically to the names used in the JSON file.
Class diagramm showing the Test and Question cass.

JSON in Android

Parsing a JSON string is not as comfortable like PHP's json_decode or Json.NET in C# but simple and straightforward using the package org.json. After creating a new JSONObject with the given string, simple values like the title can be extracted by calling jsonTest.getString("title") for example. The array, containing the question objects, can get iterated by retrieving the JSONArray from the JSON object with getJSONArray() the question objects itself by calling getJSONObject(i). None mandatory fields, like answerDescription for example, are fetched by calling optString() instead of getString(). The default value of optString() is an empty string. Very manually but it works.

public class JsonLoader implements ITestLoader {
    ...

    public void load() throws Exception {
        String jsonString = streamToString(this.stream);
        try {
            JSONObject jsonTest = new JSONObject(jsonString);

            this.test = new Test(jsonTest.getString("title"),
                    jsonTest.getString("description"));

            JSONArray jsonQuestions = jsonTest.getJSONArray("questions");

            for (int i = 0; i < jsonQuestions.length(); i++) {
                test.getQuestions().add(loadQuestion(jsonQuestions.getJSONObject(i)));
            }

        } catch (JSONException e) {
            Log.v("OpenMCT", e.getMessage());
            throw e;
        }
    }

    protected Question loadQuestion(JSONObject jsonQuestion) throws JSONException {
        Question question = new Question(
                jsonQuestion.getString("title"),
                jsonQuestion.getString("text"),
                new ArrayList<String>(), new ArrayList<Boolean>(),
                jsonQuestion.optString("answerDescription"),
                jsonQuestion.optString("hint"));

        JSONArray options = jsonQuestion.getJSONArray("options");
        for (int j = 0; j < options.length(); j++) {
            question.getOptions().add(options.getString(j));
        }

        JSONArray rightAnswers = jsonQuestion
                .getJSONArray("rightAnswers");
        for (int j = 0; j < rightAnswers.length(); j++) {
            question.getRightAnswers().add(rightAnswers.getBoolean(j));
            question.getUserAnswers().add(false);
        }
        
        return question;
    }
    ...
}

Building the UI

Now since we are able to download and parse the MCT file we can go on building a UI for taking a test. The activity for taking a test is called TestActivity. The static part of the layout, the buttons and the scroll layout, is defined classically in a normal layout XML file. The UI part containing the test question is generated by the SteppedGuiBuilder class. The SteppedGuiBuilder needs some button references for disabling, enabling and hiding them and a reference leading to layout where the question can be displayed. Calling the SteppedGuiBuilder's build() method creates the UI part for the current question.

public class TestActivity extends BaseActivity {
    protected Test test;
    protected SteppedGuiBuilder builder;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_test);

        ImageButton buttonNext = getImageButton(R.id.imageButtonNext);
        buttonNext.setOnClickListener(onButtonNextClick);
        ImageButton buttonPervious = getImageButton(R.id.imageButtonPervious);
        buttonPervious.setOnClickListener(onButtonPerviousClick);
        Button buttonHint = getButton(R.id.buttonFinish);
        buttonHint.setOnClickListener(onButtonFinishClick);
        getButton(R.id.buttonHint).setOnClickListener(onButtonHintClick);

        String fileName = getIntent().getStringExtra("FILE_NAME");
        try {
            JsonLoader loader = new JsonLoader(openFileInput(fileName));
            loader.load();

            builder = new SteppedGuiBuilder(test = loader.getTest(),
                    getApplicationContext(),
                    getLinearLayout(R.id.linearLayoutTest),
                    buttonNext,
                    buttonPervious,
                    buttonHint);
            builder.build();
            setTitle(getTitle() + " - " + test.getTitle());
        } catch (Exception e) {
            Log.v("OpenMCT", e.getMessage());
            showToastLong(R.string.test_load_error);
            finish();
        }
    }
    ...
}

The snippet below shows the listener for the next button, clicking this button increments the cursor to the next question and rebuilds the UI. The onButtonPerviousClick work similar.

OnClickListener onButtonNextClick = new OnClickListener() {

    @Override
    public void onClick(View arg0) {
        if (builder.setCurrent(builder.getCurrent() + 1)) {
            builder.build();
        }
    }

};

If the user gets stuck at a question which has a hint, the button can be clicked for showing a small hint text as a toast.

OnClickListener onButtonHintClick = new OnClickListener() {

    @Override
    public void onClick(View arg0) {
        Question currentQuestion = test.getQuestions().get(
                builder.getCurrent());
        showToastLong(currentQuestion.getHint());
    }

};

SteppedGuiBuilder

This class builds the UI for the current question, the current question can be chosen by calling setCurrent() which returns false if the index was out of bounds. The SteppedGuiBuilder has some references to buttons for disabling, enabling and hiding them if needed. By calling build() the UI for the current question gets generated. If the test has been started the cursor determining the current question is set to -1, if the cursor is set to a value less than zero instead of a question the title and the description text is gets displayed by calling buildWelcome() otherwise buildQuestion() gets called.

public void build() {
    layout.removeAllViews();

    if (current < 0) {
        buildWelcome();
        return;
    }

    buildQuestion();
}

buildQuestion() delegates the UI building to the method buildQuestionHeader() and depending on the QuestionType to buildSingleAnswerQuestion() or buildMultipleAnswerQuestion(). A question of type SINGLE will be displayed as a radio group and a question of type MULTIPLE or TRICK as a set of check boxes.

protected void buildQuestion() {
    Question question = test.getQuestions().get(current);
    hint.setVisibility(question.getHint().equals("") ? View.INVISIBLE
            : View.VISIBLE);
    
    buildQuestionHeader(question);

    switch (question.getType()) {
        case SINGLE:
            buildSingleAnswerQuestion(question);
            break;
        case MULTIPLE:
        case TRICK:
            buildMultipleAnswerQuestion(question);
            break;
    }
}

The graphic below shows which part of the screen is built by which method for a question of type SINGLE:
OpenMCT displaing a simle question of type single.

Now let's start with building the UI, the context and the layout, where the new View elements should be appended to has been already assign to the builder class in the constructor. The context is needed for creating a new View element. The layout is a linear layout, so we have to add the new View elements in the right order where the first element is displayed at the top... . How to use setTextAppearance() will be described later. Maybe you want to have a look at this trivial method below building the question header part.

protected void buildQuestionHeader(Question question) {
    TextView questionTitle = new TextView(context);
    questionTitle.setText(question.getTitle());
    questionTitle.setTextAppearance(context, R.style.QuestionTitle);
    layout.addView(questionTitle);

    TextView questionText = new TextView(context);
    questionText.setText(question.getText());
    questionText.setTextAppearance(context, R.style.QuestionText);
    layout.addView(questionText);
}

After the header has been built, which is similar for all question types, the part where the user can makes his decision has to be crated. I'm going to describe only the buildSingleAnswerQuestion() the buildMultipleAnswerQuestion() method is quite similar besides it makes use of check boxes instead of a radio group. The first thing to do is creating a RadioGroup which contains all single RadioButton's. On RadioButton per question option gets created and assigned to the RadioGroup. The OnCheckedChangeListener has to be set for reacting if the user has selected an option, please consider that the listener is the same for a CheckBox or a RadioButton. I'm using the View's tag to set some additional information to the UI elements which make it easier when evaluation a check changed event.

 protected void buildSingleAnswerQuestion(Question question) {
    RadioGroup groupOptions = new RadioGroup(context);

    int i = 0;
    int currentId = 0;
    for (String option : question.getOptions()) {
        RadioButton radioOption = new RadioButton(context);
        radioOption.setText(option);
        radioOption.setTextAppearance(context, R.style.QuestionOption);
        radioOption.setId(currentId = getId());
        radioOption.setOnCheckedChangeListener(checkBoxListener);
        radioOption.setTag(new QuestionTag<Integer>(question, i));
        groupOptions.addView(radioOption);

        if (question.getUserAnswers().get(i)) {
            groupOptions.check(currentId);
        }
        i++;
    }
    layout.addView(groupOptions);
}

The generic class QuestionTag is used to storing the question with the index of the current option, this element stands for, together.
OpenMCT QustionTag class

If a radio button or a check box is clicked, by the user, the checkBoxListener gets called. The question tag object assigned to View get fetched and the user answer list of the question object gets updated. As you can see below, through use of the helper class QuestionTag this is quite easy.

 OnCheckedChangeListener checkBoxListener = new OnCheckedChangeListener() {
    @Override
    public void onCheckedChanged(CompoundButton buttonView,
            boolean isChecked) {
        @SuppressWarnings("unchecked")
        QuestionTag<Integer> questionTag = (QuestionTag<Integer>) buttonView
                .getTag();
        questionTag.getQuestion().getUserAnswers()
                .set(questionTag.getTag(), isChecked);
    }
};

Applying Styles

The styling of the UI should not be done in code, this would be a bad style. Instead of this a styles.xml can be used for defining own styles which can be assigned to certain elements like here: radioOption.setTextAppearance(context, R.style.QuestionOption);. For keeping the styles file clean the parent attribute can be used for defining a style hierarchy which prevents repeating yourself when defining a style.

<resources xmlns:android="http://schemas.android.com/apk/res/android">

    <!--
        Base application theme, dependent on API level. This theme is replaced
        by AppBaseTheme from res/values-vXX/styles.xml on newer devices.
    -->
    <style name="AppBaseTheme" parent="android:Theme.Light">
        <!--
            Theme customizations available in newer API levels can go in
            res/values-vXX/styles.xml, while customizations related to
            backward-compatibility can go here.
        -->
    </style>

    <!-- Application theme. -->
    <style name="AppTheme" parent="AppBaseTheme">
        <!-- All customizations that are NOT specific to a particular API-level can go here. -->
    </style>
    
    <style name="OpenMCTTextAppearance" parent="@android:style/TextAppearance">
    </style>
    
    <style name="TestTitle" parent="OpenMCTTextAppearance">
      <item name="android:textSize">24sp</item>
      <item name="android:textStyle">bold</item>
      <item name="android:textColor">#41DB00</item>
    </style>
    
    <style name="TestDescription" parent="OpenMCTTextAppearance">
      <item name="android:textSize">20sp</item>
      <item name="android:textColor">#A1F43D</item>
    </style>
    
    <style name="QuestionTitle" parent="TestTitle">
      <item name="android:textSize">20sp</item>
    </style>
    
    <style name="QuestionText" parent="TestDescription">
      <item name="android:textSize">18sp</item>
    </style>
    
    <style name="QuestionOption" parent="QuestionText">
      <item name="android:textSize">17sp</item>
      <item name="android:textColor">#B7F46E</item>
    </style>
</resources>gt;20sp

Conclusion

OpenMCT conclusion

The whole source code is available at GitHub.