Principles of Compound Controls: A semi article

satya - Thu Nov 01 2012 09:36:57 GMT-0400 (Eastern Daylight Time)

Here are General steps at high level


Derive from a layout
Load layout in the constructor
Use Merge as the start of your custom layout xml
  Note: merge doesn't have any attributes
  All children of merge become the children of your layout.
If necessary assume your context is activity
  This is needed for getting fragment manager
  This allows you to use fragment dialogs
  inside your compound control.
You can use fragment dialogs
Create a class for your fragment dialog
  Use fragment manager from the activity
  Set the arguments bundle with your compound control id
  Restore pointer to compound control onActivityCreated()
Override view state management
Read custom attributes
  Use enums where appropriate

These steps are based on the initial approach and if I discover something new I will update this or post here. I will give out source code samples for each of the steps indicated here.

satya - Thu Nov 01 2012 09:39:01 GMT-0400 (Eastern Daylight Time)

Goal of the sample that I am going to cover

Create a custom compound control that allows you to specify 2 dates and get the duration in weeks or days between those dates. The custom controll will have two text fields for the two dates and two buttons to invoke fragment date picker dialogs.

satya - Thu Nov 01 2012 09:41:30 GMT-0400 (Eastern Daylight Time)

Derive from a layout


public class DurationControl 
extends LinearLayout
implements android.view.View.OnClickListener
{
...

The onclick listener is there to listen to the two buttons and kick off the fragment dialog to gather the dates.

satya - Thu Nov 01 2012 09:42:57 GMT-0400 (Eastern Daylight Time)

Here is the layout file


<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout
    android:orientation="horizontal"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
>
<TextView  
   android:id="@+id/fromDate"
    android:layout_width="0dp" 
    android:layout_height="wrap_content" 
    android:text="Debut Text Appears here"
    android:layout_weight="70" 
    />
<Button  
   android:id="@+id/fromButton"
    android:layout_width="0dp" 
    android:layout_height="wrap_content" 
    android:text="Go"
    android:layout_weight="30" 
    />
</LinearLayout>        
<LinearLayout
    android:orientation="horizontal"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
>
<TextView  
   android:id="@+id/toDate"
    android:layout_width="0dp" 
    android:layout_height="wrap_content" 
    android:text="Debut Text Appears here"
    android:layout_weight="70" 
    />
<Button  
   android:id="@+id/toButton"
    android:layout_width="0dp" 
    android:layout_height="wrap_content" 
    android:text="Go"
    android:layout_weight="30" 
    />
</LinearLayout>        
</merge>

Notice that the merge has no additional attributes in it. All children of merge become the children of parent layout specified by this control.

satya - Fri Nov 02 2012 15:38:17 GMT-0400 (Eastern Daylight Time)

Here is how you load the custom layout for the compound control in the constructor


public DurationControl(Context context) {
  super(context);
  initialize(context);
}

...other constructors that are there to read custom attributes
...Which also call initialize(context)

private void initialize(Context context)  {

  //Get the layout inflater
  LayoutInflater lif = (LayoutInflater) 
  context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);

  //inflate the custom layout of previous listing
  //Use the second argument to attach the layout
  //as a child of this layout
  lif.inflate(R.layout.duration_view_layout, this);
  
  //Initialize the buttons
  Button b = (Button)this.findViewById(R.id.fromButton);
  b.setOnClickListener(this);
  b = (Button)this.findViewById(R.id.toButton);
  b.setOnClickListener(this);
  
  //Allow view state management
  this.setSaveEnabled(true);
}

satya - Thu Nov 01 2012 09:43:27 GMT-0400 (Eastern Daylight Time)

Here is how this compound is specified in your activity


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:apptemplate="http://schemas.android.com/apk/res/com.androidbook.compoundControls"    
    android:orientation="vertical"
    android:layout_width="fill_parent"
    android:layout_height="match_parent"
    >
<TextView  
   android:id="@+id/text2"
    android:layout_width="fill_parent" 
    android:layout_height="wrap_content" 
    android:text="Custom Hello"
    />
<com.androidbook.compoundControls.DurationControl
   android:id="@+id/durationControlId"
    android:orientation="vertical"
    android:layout_width="fill_parent" 
    android:layout_height="wrap_content" 
    /> 
<TextView  
   android:id="@+id/text1"
    android:layout_width="fill_parent" 
    android:layout_height="wrap_content" 
    android:text="Debug stuff shows here"
    />
</LinearLayout>

satya - Thu Nov 01 2012 09:44:31 GMT-0400 (Eastern Daylight Time)

So whether to layout your controls in merge vertically or otherwise is specified

...where you specify the custom component and not in the merge node!!

satya - Thu Nov 01 2012 09:48:13 GMT-0400 (Eastern Daylight Time)

If you need a fragment manager from your custom view


private FragmentManager getFragmentManager()
    {
      Context c = getContext();
      if (c instanceof Activity)
      {
         return ((Activity)c).getFragmentManager();
      }
      throw new RuntimeException("Activity context expected instead");
    }

I could not find a way to reliably get a fragment manager all the time. So I have assumed that the context is an activity. If not throw an exception and raise hands. Fragment managers are attached to activity. So not sure if this control would work at all if the context is not an activity.

of course because this component is using fragment dialogs we are in this predicament. You could have a compound control that doesn't invoke fragment dialogs. In that case you are fine and the rest of the article should be valid.

satya - Thu Nov 01 2012 09:49:18 GMT-0400 (Eastern Daylight Time)

Here is how you invoke fragment dialogs from buttons


public void onClick(View v)
   {
      Button b = (Button)v;
      if (b.getId() == R.id.fromButton)
      {
          DialogFragment newFragment = new DatePickerFragment(this,R.id.fromButton);
           newFragment.show(getFragmentManager(), "com.androidbook.tags.datePicker");
           return;
      } 
      
      //Otherwise
       DialogFragment newFragment = new DatePickerFragment(this,R.id.toButton);
        newFragment.show(getFragmentManager(), "com.androidbook.tags.datePicker");
        return;
   }//eof-onclick

satya - Thu Nov 01 2012 09:52:01 GMT-0400 (Eastern Daylight Time)

Here is how the DatePickerFragment looks like that invokes the date picker dialog


public class DatePickerFragment extends DialogFragment
   implements DatePickerDialog.OnDateSetListener 
{
   public static String tag = "DatePickerFragment";
   private DurationControl parent;
   private int buttonId;
   
   public DatePickerFragment(DurationControl inParent, int inButtonId)
   {
      parent = inParent;
      buttonId = inButtonId;
      Bundle argsBundle = this.getArguments();
      if (argsBundle == null)
      {
         argsBundle = new Bundle();
      }
      argsBundle.putInt("parentid", inParent.getId());
      argsBundle.putInt("buttonid", buttonId);
      this.setArguments(argsBundle);
   }
   
   //Default constructor
   public DatePickerFragment()
   {
      Log.d(tag,"constructed");
   }
   @Override
   public Dialog onCreateDialog(Bundle savedInstanceState) 
   {
      //this.establishParent();
      // Use the current date as the default date in the picker
      final Calendar c = Calendar.getInstance();
      int year = c.get(Calendar.YEAR);
      int month = c.get(Calendar.MONTH);
      int day = c.get(Calendar.DAY_OF_MONTH);
      
      // Create a new instance of DatePickerDialog and return it
      return new DatePickerDialog(getActivity(), this, year, month, day);
   }
   
   public void onDateSet(DatePicker view, int year, int month, int day) {
   // Do something with the date chosen by the user
      parent.reportTransient(tag, "date clicked");
      parent.onDateSet(buttonId, year, month, day);
   }
   
   @Override
   public void onActivityCreated(Bundle savedInstanceState) {
      // TODO Auto-generated method stub
      super.onActivityCreated(savedInstanceState);
      Log.d(tag,"DatePickerFragment onActivity created called");
      this.establishParent();
   }
   
   private void establishParent()
   {
      if (parent != null) return;
      Log.d(tag, "establishing parent");
      int parentid = this.getArguments().getInt("parentid");
      buttonId =  this.getArguments().getInt("buttonid");
      View x = this.getActivity().findViewById(parentid);
      if (x == null)
      {
         throw new RuntimeException("Sorry not able to establish parent on restart");
      }
      parent = (DurationControl)x;
   }
}

There are lot of cool things here around dancing with fragment specific details.

satya - Thu Nov 01 2012 09:54:39 GMT-0400 (Eastern Daylight Time)

Here is how you call a constructor the first time and put them in a bundle


public DatePickerFragment(DurationControl inParent, int inButtonId)
   {
      parent = inParent;
      buttonId = inButtonId;
      Bundle argsBundle = this.getArguments();
      if (argsBundle == null)
      {
         argsBundle = new Bundle();
      }
      argsBundle.putInt("parentid", inParent.getId());
      argsBundle.putInt("buttonid", buttonId);
      this.setArguments(argsBundle);
   }

Notice we save the view id so that we can get back to it when the device rotates. We also save the button id that invokes this dialog so that we can pass it back through the call back funtion. We have one dialog fragment that gets invoked from both buttons. So the caller (our compound view) needs to know when called back which button invoked this date dialog.

satya - Thu Nov 01 2012 09:55:58 GMT-0400 (Eastern Daylight Time)

Here is how restore the parent


@Override
   public void onActivityCreated(Bundle savedInstanceState) {
      // TODO Auto-generated method stub
      super.onActivityCreated(savedInstanceState);
      Log.d(tag,"DatePickerFragment onActivity created called");
      this.establishParent();
   }
   
   private void establishParent()
   {
      if (parent != null) return;
      Log.d(tag, "establishing parent");
      int parentid = this.getArguments().getInt("parentid");
      buttonId =  this.getArguments().getInt("buttonid");
      View x = this.getActivity().findViewById(parentid);
      if (x == null)
      {
         throw new RuntimeException("Sorry not able to establish parent on restart");
      }
      parent = (DurationControl)x;
   }

when the device is rotated while we are in this dialog the parent view needs to be reestablished. And also the button ids needs to be put back.

satya - Thu Nov 01 2012 09:57:34 GMT-0400 (Eastern Daylight Time)

Here is the callback function parent.onDateSet()


public void onDateSet(int buttonId, int year, int month, int day)
   {
      Calendar c = getDate(year,month,day);
      if (buttonId == R.id.fromButton)
      {
         setFromDate(c);
         return;
      }
      setToDate(c);
   }

   private void setFromDate(Calendar c)
   {
      if (c == null) return; 
      this.fromDate = c;
      TextView tc = (TextView)findViewById(R.id.fromDate);
      tc.setText(getDateString(c));
   }
   private void setToDate(Calendar c)
   {
      if (c == null) return; 
      this.toDate = c;
      TextView tc = (TextView)findViewById(R.id.toDate);
      tc.setText(getDateString(c));
   }
   private Calendar getDate(int year, int month, int day)
   {
      Calendar c = Calendar.getInstance();
      c.set(year,month,day);
      return c;
   }
   public static String getDateString(Calendar c)
   {
     if(c == null) return "null";
     SimpleDateFormat df = new SimpleDateFormat("MM/dd/yyyy");
     df.setLenient(false);
     String s = df.format(c.getTime());
     return s;   
   }

satya - Thu Nov 01 2012 09:59:21 GMT-0400 (Eastern Daylight Time)

Taking hold of the view state management

if we use this compound control multiple times in an activity then the ids for the text views and the button views will get repeated. This will not work for managing their own view state.

So the compound control needs to take control of this and not pass on the save and restore methods. What follows is how you do that.

satya - Thu Nov 01 2012 10:01:12 GMT-0400 (Eastern Daylight Time)

Override dispatchSaveInstanceState and call dispatchFreezeSelfonly


@Override
   protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {
      //Don't call this so that children won't be explicitly saved
      //super.dispatchSaveInstanceState(container);
      //Call your self onsavedinstancestate
      super.dispatchFreezeSelfOnly(container);
      Log.d(tag,"in dispatchSaveInstanceState");
   }

when you do this, you are calling onSaveInstanceState on your self but not on your children. This done through the base class's dispatchFreezeSelfOnly. Read the research logs on why.

satya - Thu Nov 01 2012 10:01:33 GMT-0400 (Eastern Daylight Time)

Do the same for dispatchRestoreInstanceState


@Override
   protected void dispatchRestoreInstanceState(
         SparseArray<Parcelable> container) {
      //Don't call this so that children won't be explicitly saved
      //.super.dispatchRestoreInstanceState(container);
      super.dispatchThawSelfOnly(container);
      Log.d(tag,"in dispatchRestoreInstanceState");
   }

satya - Thu Nov 01 2012 10:01:55 GMT-0400 (Eastern Daylight Time)

This approach is just cut and paste every time you do this compound controls

This approach is just cut and paste every time you do this compound controls

satya - Thu Nov 01 2012 10:02:25 GMT-0400 (Eastern Daylight Time)

Implement your own saved state


/*
     * ***************************************************************
     * Saved State inner static class
     * ***************************************************************
     */
    public static class SavedState extends BaseSavedState {
       //null values are allowed
        private Calendar fromDate;
        private Calendar toDate;

        SavedState(Parcelable superState) {
            super(superState);
        }
        @Override
        public void writeToParcel(Parcel out, int flags) {
            super.writeToParcel(out, flags);
            
            if (fromDate != null)
            {
               out.writeLong(fromDate.getTimeInMillis());
            }
            else
            {
               out.writeLong(-1L);
            }
            if (fromDate != null)
            {
               out.writeLong(toDate.getTimeInMillis());
            }
            else
            {
               out.writeLong(-1L);
            }
        }

        @Override
        public String toString() {
           StringBuffer sb  = new StringBuffer("fromDate:" + DurationControl.getDateString(fromDate));
           sb.append("fromDate:" + DurationControl.getDateString(toDate));
           return sb.toString();
        }

        @SuppressWarnings("hiding")
        public static final Parcelable.Creator<SavedState> CREATOR
                = new Parcelable.Creator<SavedState>() {
            public SavedState createFromParcel(Parcel in) {
                return new SavedState(in);
            }
            public SavedState[] newArray(int size) {
                return new SavedState[size];
            }
        };

        //Read back the values
        private SavedState(Parcel in) {
            super(in);
            
            //Read the from date
            long lFromDate = in.readLong();
            if (lFromDate == -1)         {
               fromDate = null;
            }
            else          {
               fromDate = Calendar.getInstance();
               fromDate.setTimeInMillis(lFromDate);
            }
            //Read the from date
            long lToDate = in.readLong();
            if (lFromDate == -1)         {
               toDate = null;
            }
            else          {
               toDate = Calendar.getInstance();
               toDate.setTimeInMillis(lToDate);
            }
        }
    }//eof-state-class

satya - Thu Nov 01 2012 10:04:13 GMT-0400 (Eastern Daylight Time)

Now you can do this


@Override
   protected void onRestoreInstanceState(Parcelable state) {
      Log.d(tag,"in onRestoreInstanceState");
      if (!(state instanceof SavedState)) {
           super.onRestoreInstanceState(state);
           return;
      }    
      //it is our state
      SavedState ss = (SavedState)state;
      //Peel it and give the child to the super class
      super.onRestoreInstanceState(ss.getSuperState());
      //this.fromDate = ss.fromDate;
      //this.toDate= ss.toDate;
      this.setFromDate(ss.fromDate);
      this.setToDate(ss.toDate);
   }

   @Override
   protected Parcelable onSaveInstanceState() {
      Log.d(tag,"in onSaveInstanceState");
       Parcelable superState = super.onSaveInstanceState();
       SavedState ss = new SavedState(superState);
       ss.fromDate = this.fromDate;
       ss.toDate = this.toDate;
       return ss;      
   }

Notice that there is no good way to pass the local variable to the state class through the constructor. Actually try it. You will know why. So we might as well declare them public and not worry about get/set methods.

satya - Thu Nov 01 2012 10:04:49 GMT-0400 (Eastern Daylight Time)

What custom component is complete with out custom attributes


<resources>
<declare-styleable name="CircleView">
    <attr name="strokeWidth" format="integer"/>
    <attr name="strokeColor" format="color|reference" />
</declare-styleable>
<declare-styleable name="DurationComponent">
    <attr name="durationUnits">
      <enum name="days" value="1"/>
      <enum name="weeks" value="2"/>
    </attr>
</declare-styleable>
</resources>

satya - Thu Nov 01 2012 10:05:26 GMT-0400 (Eastern Daylight Time)

Here is how you read these enums


public DurationControl(Context context) {
      super(context);
      // TODO Auto-generated constructor stub
      initialize(context);
   }

   public DurationControl(Context context, AttributeSet attrs, int defStyle) {
      super(context, attrs, defStyle);
      TypedArray t = context.obtainStyledAttributes(attrs,R.styleable.DurationComponent,0,0);
        durationUnits = t.getInt(R.styleable.DurationComponent_durationUnits, durationUnits);
        t.recycle();
      initialize(context);
   }

   public DurationControl(Context context, AttributeSet attrs) {
      this(context, attrs,0);
   }
   
   private void initialize(Context context)
   {
      LayoutInflater lif = (LayoutInflater) 
      context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        lif.inflate(R.layout.duration_view_layout, this);
        Button b = (Button)this.findViewById(R.id.fromButton);
        b.setOnClickListener(this);
        b = (Button)this.findViewById(R.id.toButton);
        b.setOnClickListener(this);
        this.setSaveEnabled(true);
   }

satya - Thu Nov 01 2012 10:59:49 GMT-0400 (Eastern Daylight Time)

Here are some images: What this control looks like

This control is between the welocme text and the debug text. See the layout file above in the source code. The GO button will bring up the date picker. See images below.

satya - Thu Nov 01 2012 11:15:25 GMT-0400 (Eastern Daylight Time)

Date picker

satya - Thu Nov 01 2012 11:16:49 GMT-0400 (Eastern Daylight Time)

Date captured

satya - Thu Nov 01 2012 11:17:47 GMT-0400 (Eastern Daylight Time)

Managing rotation while the dialog is up: landscape

satya - Thu Nov 01 2012 11:18:24 GMT-0400 (Eastern Daylight Time)

Date captured in landscape

satya - Thu Nov 01 2012 11:19:18 GMT-0400 (Eastern Daylight Time)

Rotated back to portrait: save state of the compound view gets kciked in