Wednesday, April 15, 2015

My Notes: UI structure

I am planning on doing a hybrid fragment-oriented design.  The activity will display the notes, but the note editor, toolbars, etc. will be fragments. 
I considered having the notes display in its own fragment, but that seems very messy with the interaction with the add button and other fragments.  Besides, the activity was looking a little sparse in that design.
Right now I only have the MainActivity and the MainToolbarFragment.  Since a picture is worth a thousand words, here it is:
image

MainActivity

The MainActivity is the entry point for the app.  It is the primary (and maybe only) screen that the user will see.  It will directly manage the presentation and creation of the note data.  The notes are displayed in a RecyclerView that displays individual notes in CardViews.  The relevant code in activity is:

/**
 * The primary activity.  It provides a list of notes and the tools to manage them.
 */
public class MainActivity extends Activity implements LoaderManager.LoaderCallbacks<List<Note>> {

    /**
     * The identifier of the main loader.  This loads the color filtered note list.
     */
    static final int MAIN_LOADER_ID = 0;


    /**
     * UI element that manages the primary toolbar.
     */
    MainToolbarFragment mMainToolbarFragment;


    /**
     * UI element to display the note data.
     */
    @InjectView(R.id.activity_main_notes_recycler_view)
    RecyclerView mCardsRecyclerView;


    /**
     * UI element to allow a user to create a new note.
     */
    @InjectView(R.id.activity_main_add_view)
    View mAddView;


    /**
     * The adapter to manage the display of cards in {@link #mCardsRecyclerView}.
     */
    CardAdapter mCardAdapter;


    @Override
    protected void onCreate(final Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ButterKnife.inject(this);

        mCardAdapter = new CardAdapter(null);

        int columnCount = getResources().getInteger(R.integer.card_column_count);
        float cardDividerDimen = getResources().getDimension(R.dimen.note_divider_size);

        mCardsRecyclerView.addItemDecoration(new DividerItemDecoration((int) cardDividerDimen));
        mCardsRecyclerView.setLayoutManager(new GridLayoutManager(this, columnCount));
        mCardsRecyclerView.setAdapter(mCardAdapter);

        FragmentManager manager = getFragmentManager();
        FragmentTransaction transaction = manager.beginTransaction();

        mMainToolbarFragment = new MainToolbarFragment();

        transaction.replace(R.id.activity_main_toolbar, mMainToolbarFragment);

        transaction.commit();
        getLoaderManager().initLoader(MAIN_LOADER_ID, null, this);
    }
...
}

As stated above, this class uses a RecyclerView instead of a ListView.  This component is new to lollipop and improves upon the AdapterViews from previous android SDKs.  There are some quirks with them (I’ll cover what I have found so far in another post), but I highly recommend them for new work.  They are more flexible and more efficient than the old AdapterViews.

The xml to support the code can be found in activity_main.xml:





<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="@dimen/activity_margin">

    <FrameLayout
        android:id="@+id/activity_main_toolbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentTop="true" />

    <android.support.v7.widget.RecyclerView
        android:id="@+id/activity_main_notes_recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_marginTop="10dp"
        android:layout_below="@id/activity_main_toolbar" />

    <include
        layout="@layout/include_floating_add_button"
        android:id="@+id/activity_main_add_view"
        android:layout_width="@dimen/add_button_size"
        android:layout_height="@dimen/add_button_size"
        android:layout_marginRight="10dp"
        android:layout_marginEnd="10dp"
        android:layout_marginBottom="10dp"
        android:layout_alignParentBottom="true"
        android:layout_alignParentRight="true"/>

</RelativeLayout>

There really isn’t much to say here.  It is pretty straightforward xml.  The only things to note are the include for the add button and styling. 

The include is an xml feature that helps to simplify and reuse the xml.  In this case I did it to keep the xml streamlined.  I tend to find deep indenting hard to read even with the code collapse features in AndroidStudio. 

You will also note that I didn’t use any styles.  I tend to only use them for repeated features (a common text styling, etc) or when I want a common layout but different styles based on the device configurations.  I am not a big fan of using a style for each component.  I think this makes the code hard to read since it reduces the “glancibility” factor for the file.

MainToolbarFragment


This is the only fragment I have defined right now.  It is the primary toolbar for the activity.  It consists of two sections.  The first section is an header bar with a filter button.

image

The second section is the color filter options:

image

The xml to support this can be found in fragment_toolbar.xml:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:custom="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="5dp"
    android:minHeight="?attr/actionBarSize"
    android:background="?attr/colorPrimary"
    android:elevation="@dimen/toolbar_elevation">

    <TextView
        android:text="@string/app_name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentTop="true"
        android:layout_alignParentLeft="true"
        android:textAppearance="?android:attr/textAppearanceLarge"
        android:textColor="@android:color/white"/>

    <com.fsk.common.presentation.components.CheckableImageView
        android:id="@+id/fragment_toolbar_filter_toggle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentTop="true"
        android:layout_alignParentRight="true"
        android:layout_marginRight="10dp"
        android:button="@null"
        android:src="@drawable/filter_background"/>

    <android.support.v7.widget.RecyclerView
        android:id="@+id/fragment_toolbar_filter_view"
        android:layout_height="@dimen/note_filter_toolbar_height"
        android:layout_width="match_parent"
        android:layout_below="@id/fragment_toolbar_filter_toggle"
        android:layout_gravity="center"
        android:visibility="gone"/>
</RelativeLayout>

Once again I am using a RecyclerView.  In this case it is for color filter selector. Each item in the RecycleView is a toggle that allows the user turn on or off that color.  By default, I do not show this View.  It only appears when the user toggles the main the filter option.

The code to support the toolbar is found in MainToolbarFragment.java.  This code is a little more complicated so I will break down by behavior.  This is the main setup code:


    /**
     * UI element to show/hide the color filter sub-menu.
     */
    @InjectView(R.id.fragment_toolbar_filter_toggle)
    CheckableImageView mShowFilterToggle;


    /**
     * UI element that allows the user to filter notes by color.
     */
    @InjectView(R.id.fragment_toolbar_filter_view)
    RecyclerView mFilterRecyclerView;

...


    @Override
    public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
                             final Bundle savedInstanceState) {

        View view = inflater.inflate(R.layout.fragment_toolbar, container, false);
        ButterKnife.inject(this, view);

        mShowFilterToggle.setOnCheckedChangeListener(mFilterToggleCheckedChangeListener);

        Context context = inflater.getContext();
        Resources resources = context.getResources();
        int columnCount = resources.getInteger(R.integer.color_filter_column_count);
        float dividerDimension = resources.getDimension(R.dimen.color_filter_divider);

        mFilterRecyclerView.setLayoutManager(new GridLayoutManager(context, columnCount));
        mFilterRecyclerView.addItemDecoration(new DividerItemDecoration((int) dividerDimension));
        mFilterRecyclerView.setAdapter(new FilterColorAdapter(context));

        return view;
    }

This code initializes the UI components for the toolbar.  It is basically, the standard boilerplate for initializing views.

The next part is more interesting.  When the user clicks on the filter I want it to reveal the Color Filter RecyclerView with a nice animation.  My preferred animation is the CircularReveal. 

Unfortunately, that animator is not available in the compatibility library so I can’t use it for non-Lollipop devices.  I still want similar behavior were the filter view appears from the the upper left corner and the collapses back to the same corner.  To do that I created two animators to support that animation, filter_toolbar_open.xml and filter_toolbar_close.xml.

Here is the content of filter_toolbar_close.xml:

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
     android:duration="@android:integer/config_longAnimTime">

    <objectAnimator
        android:valueFrom="1.0"
        android:valueTo="0.0"
        android:valueType="floatType"
        android:propertyName="scaleY"/>
    <objectAnimator
        android:valueFrom="1.0"
        android:valueTo="0.0"
        android:valueType="floatType"
        android:propertyName="scaleX"/>
</set>

And here is the filter_toolbar_open.xml:

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
     android:duration="@android:integer/config_longAnimTime">

    <objectAnimator
        android:valueFrom="0.0"
        android:valueTo="1.0"
        android:valueType="floatType"
        android:propertyName="scaleY"/>
    <objectAnimator
        android:valueFrom="0.0"
        android:valueTo="1.0"
        android:valueType="floatType"
        android:propertyName="scaleX"/>
</set>

Both of these are ObjectAnimators that change the scale from 1 to 0 and 0 to 1 respectively for the close and open.  By default, animations occur around the upper left corner, but I need them to orient to the upper right instead.  This is done by changing the Views pivot position via setPivotX() and setPivotY().

Intermission: Lollipop Logic Breaks


Lollipop changed a lot of stuff.  Some of the new features are supported in the compatibility libraries, but a lot of the really interesting stuff isn’t.  I really want to avoid cluttering the code with tons of logic breaks and multiple methods for lollipop and pre-lollipop. 

For instance, this is what it looks like with the logic breaks in the class:


    /**
     * Show or hide the note filter options.
     *
     * @param enabled
     *         true to show the note filter.  false will hide it.
     */
    private void enableFilterToolbar(boolean enabled) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            startLollipopAnimator(enabled);
        }
        else {
            startOldAnimator(enabled);
        }
    }
    
    private void startLollipopAnimator(boolean start) {
        int x = (mShowFilterToggle.getLeft() + mShowFilterToggle.getRight()) / 2;
        int y = (mShowFilterToggle.getTop() + mShowFilterToggle.getBottom()) / 2;
        
        int radius = getView().getWidth();
        int startRadius = start ? 0 : radius;
        int endRadius = start ? radius : 0;
        
        ViewAnimationUtils.createCircularReveal(mFilterRecyclerView, x, y, startRadius,
                                                        endRadius).start();
    }

    private void startOldAnimator(boolean start) {
        int x = (mShowFilterToggle.getLeft() + mShowFilterToggle.getRight()) / 2;
        int y = (mShowFilterToggle.getTop() + mShowFilterToggle.getBottom()) / 2;
        int animatorResource =
                (start) ? R.animator.filter_toolbar_open : R.animator.filter_toolbar_close;

        int pivotX = (int) getView().getX() + getView().getWidth();
        mFilterRecyclerView.setPivotX(pivotX);
        mFilterRecyclerView.setPivotY(0);

        AnimatorInflater.loadAnimator(getView().getContext(), animatorResource).start();
    }


It is ugly and will be hard to maintain in the long term.  Since these are going to occur all over the place I am opting for different pattern.

Namely, I am going to use a Factory and Builder pattern. Basically, whenever I hit a big logic break like above I a builder class and then extend it into a compatibility version. I then update a factory to return the current version of builder based on the SDK version,

The main builder is used for lollipop and the compat vesion works for non-Lollipop builds.  Instead of having the logic breaks spread across the class, I instead create a different builder object.  Then the logic for the SDK version exists within an encapsulated object.  This simplifies the code and makes it more maintainable.  When it is time to make Lollipop the minimum version I can delete the compat version and then just fix a few simple compile errors.

You can see this pattern on the toolbar reveal animations:

ToolbarAnimatorBuilder.java:

/**
 * A builder to create the toolbar opening and closing animations.
 */
public class ToolbarAnimatorBuilder {

    /**
     * The focal x-coordinate for the animation.
     */
    protected int mX;


    /**
     * The focal y-coordinate for the animation.
     */
    protected int mY;


    /**
     * The widest radius for the animation.
     */
    protected int mRadius;


    /**
     * The target view for the animation.
     */
    protected View mTarget;


    /**
     * A flag to indicate if the target view is opening or closing.  true opens the toolbar.
     */
    protected boolean mReveal;


    /**
     * Set the focal point for the animation.
     *
     * @param x
     *         the coordinate along the x-axis.
     * @param y
     *         the coordinate along the y-axis.
     *
     * @return {@link ToolbarAnimatorBuilder} to allow for call chaining.
     */
    public ToolbarAnimatorBuilder setFocus(int x, int y) {
        mX = x;
        mY = y;

        return this;
    }


    /**
     * Set the widest radius for the animation.
     *
     * @param radius
     *         the widest radius for the animation.
     *
     * @return {@link ToolbarAnimatorBuilder} to allow for call chaining.
     */
    public ToolbarAnimatorBuilder setRadius(int radius) {
        mRadius = radius;

        return this;
    }


    /**
     * Set the target for the animation.
     *
     * @param target
     *         the {@link View} being revealed or hidden.
     *
     * @return {@link ToolbarAnimatorBuilder} to allow for call chaining.
     */
    public ToolbarAnimatorBuilder setTarget(View target) {
        mTarget = target;
        return this;
    }


    /**
     * Set a status indicating if the toolbar is being revealed or hidden.
     *
     * @param reveal
     *         true to reveal the toolbar.
     *
     * @return {@link ToolbarAnimatorBuilder} to allow for call chaining.
     */
    public ToolbarAnimatorBuilder setReveal(boolean reveal) {
        mReveal = reveal;
        return this;
    }


    /**
     * Build the {@link android.animation.Animator} based on the supplied configuration.
     *
     * @return the created animator.
     *
     * @throws java.lang.NullPointerException
     *         when the target view is null.
     * @throws java.lang.IllegalArgumentException
     *         when the radius is negative.
     */
    public Animator build() {
        validate();
        Animator returnValue = createAnimator();
        returnValue.setTarget(mTarget);
        returnValue.addListener(new ToolbarAnimatorListener(getEndVisibility(), mTarget));

        return returnValue;
    }


    /**
     * Validate the configuration is okay for building an {@link Animator}.
     *
     * @throws java.lang.NullPointerException
     *         when the target view is null.
     * @throws java.lang.IllegalArgumentException
     *         when the radius is negative.
     */
    protected void validate() {
        Preconditions.checkNotNull(mTarget);
        Preconditions.checkArgument(mRadius >= 0);
    }


    /**
     * Create the animator for the supplied configuration.
     *
     * @return an {@link android.animation.Animator} to hide or show the target.
     */
    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    protected Animator createAnimator() {
        return ViewAnimationUtils
                .createCircularReveal(mTarget, mX, mY, getStartRadius(), getEndRadius());
    }


    /**
     * Get the visibility for the target view after the animation completes.
     *
     * @return {@link android.view.View#INVISIBLE}, {@link android.view.View#VISIBLE}, or {@link
     * android.view.View#GONE}
,.     */
    protected int getEndVisibility() {
        return (mReveal) ? View.VISIBLE : View.GONE;
    }


    /**
     * Get the starting radius for animation.
     *
     * @return the starting radius for the animation.
     */
    protected int getStartRadius() {
        return (mReveal) ? 0 : mRadius;
    }


    /**
     * Get the final radius for animation.
     *
     * @return the final radius for the animation.
     */
    protected int getEndRadius() {
        return (mReveal) ? mRadius : 0;
    }
}

This provides methods to setup the target of the animations, the focal point, the radius (needed for the circular reveal) and the type of reveal (hide or show).  Once the properties for the animator are set, it can be built via the createAnimator() method.  In this case, I am setting up the circular reveal, but in the compat version I use the animator xml files.

ToolbarAnimatorBuilderCompat.java:

/**
 * A builder to create the toolbar opening and closing animations for Pre-Lollipop devices.
 */
public class ToolbarAnimatorBuilderCompat extends ToolbarAnimatorBuilder {
    @Override
    protected Animator createAnimator() {
        int animatorResource =
                (mReveal) ? R.animator.filter_toolbar_open : R.animator.filter_toolbar_close;

        int pivotX = (int) mTarget.getX() + mTarget.getWidth();
        mTarget.setPivotX(pivotX);
        mTarget.setPivotY(0);

        return AnimatorInflater.loadAnimator(mTarget.getContext(), animatorResource);
    }
}

This inherits from the ToolbarAnimatorBuilder and overrides the createAnimator() and changes it to use the xml animators files instead.  I also set the PivotX and PivotY here.  The pivot positions change the focus point for the animation to occur around this point.

Then I added the Factory, AnimationBuilderFactory:




/**
 * A factory to return Animation Builders.
 */
public enum AnimationBuilderFactory {
    ;


    /**
     * Creates the correct version of the {@link ToolbarAnimatorBuilder} to support the current
     * SDK.
     *
     * @return the correct version of the {@link ToolbarAnimatorBuilder} to support the current
     * SDK.
     */
    public static ToolbarAnimatorBuilder getFilterToolbarAnimatorBuilder() {
        return(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) ?
                                         new ToolbarAnimatorBuilder() :
                                         new ToolbarAnimatorBuilderCompat();
    }
}


Back to the Fragment…


In order to use the Builders all I have to do is add a very small logic break into the fragment:

/**
 * Show or hide the note filter options.
 *
 * @param enabled
 *         true to show the note filter.  false will hide it.
 */
private void enableFilterToolbar(boolean enabled) {
    ToolbarAnimatorBuilder builder = AnimationBuilderFactory.getFilterToolbarAnimatorBuilder();

    try {
        int x = (mShowFilterToggle.getLeft() + mShowFilterToggle.getRight()) / 2;
        int y = (mShowFilterToggle.getTop() + mShowFilterToggle.getBottom()) / 2;
        builder.setTarget(mFilterRecyclerView)
               .setFocus(x, y)
               .setRadius(getView().getWidth())
               .setReveal(enabled).build()
               .start();
    }
    catch (NullPointerException npe) {
        //Don't do anything except prevent the crash propagation.
    }
}

Commits


The commits associated with these changes can be found at github here and the factory update can be found at github here.

No comments :

Post a Comment