package com.simplicityapks.reminderdatepicker.lib; import android.content.Context; import android.content.res.Resources; import android.content.res.XmlResourceParser; import android.os.Bundle; import android.os.Parcelable; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.XmlRes; import android.util.AttributeSet; import android.util.Log; import android.view.View; import android.widget.AdapterView; import android.widget.SpinnerAdapter; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import java.io.IOException; import java.util.ArrayList; import java.util.List; /** * Base class for both DateSpinner and TimeSpinner. * This is a Spinner with the following additional, optional features: * * 1. A custom last list item (footer), which won't get selected on click. Instead, onFooterClick() will be called. * 2. Items with secondary text, due to integration with {@link com.simplicityapks.reminderdatepicker.lib.PickerSpinnerAdapter} * 3. Select items which are not currently in the spinner items (use {@link #selectTemporary(TwinTextItem)}. * 4. Dynamic and easy modifying the spinner items without having to worry about selection changes (use the ...AdapterItem...() methods) */ public abstract class PickerSpinner extends android.support.v7.widget.AppCompatSpinner { public static final String XML_ATTR_ID = "id"; public static final String XML_ATTR_TEXT = "text"; // Indicates that the onItemSelectedListener callback should not be passed to the listener. private final ArrayList<Integer> interceptSelectionCallbacks = new ArrayList<>(); // Indicates that the selection should be restored after initialization (setSelection has not been called externally) private boolean restoreTemporarySelection = false; // Indicates that the temporary item should be reselected after an item is removed private boolean reselectTemporaryItem = false; /** * Construct a new PickerSpinner with the given context's theme. * @param context The Context the view is running in, through which it can access the current theme, resources, etc. */ public PickerSpinner(Context context) { this(context, null); } /** * Construct a new PickerSpinner with the given context's theme and the supplied attribute set. * @param context The Context the view is running in, through which it can access the current theme, resources, etc. * @param attrs The attributes of the XML tag that is inflating the view. */ public PickerSpinner(Context context, AttributeSet attrs) { this(context, attrs, 0); } /** * Construct a new PickerSpinner with the given context's theme, the supplied attribute set, and default style. * @param context The Context the view is running in, through which it can access the current theme, resources, etc. * @param attrs The attributes of the XML tag that is inflating the view. * @param defStyle The default style to apply to this view. If 0, no style will be applied (beyond * what is included in the theme). This may either be an attribute resource, whose * value will be retrieved from the current theme, or an explicit style resource. */ public PickerSpinner(Context context, AttributeSet attrs, int defStyle) { super(context, attrs); initAdapter(context); } protected void initAdapter(Context context) { CharSequence footer = getFooter(); TwinTextItem footerItem = footer == null? null : new TwinTextItem.Simple(footer, null); // create our simple adapter with default layouts and set it here: PickerSpinnerAdapter adapter = new PickerSpinnerAdapter(context, getSpinnerItems(), footerItem); setAdapter(adapter); } @NonNull @Override public Parcelable onSaveInstanceState() { // our temporary selection will not be saved if(getSelectedItemPosition() == getAdapter().getCount()) { Bundle state = new Bundle(); state.putParcelable("superState", super.onSaveInstanceState()); // save the TwinTextItem using its toString() method state.putString("temporaryItem", getSelectedItem().toString()); return state; } else return super.onSaveInstanceState(); } @Override public void onRestoreInstanceState(Parcelable state) { if(state instanceof Bundle) { Bundle bundle = (Bundle) state; super.onRestoreInstanceState(bundle.getParcelable("superState")); final String tempItem = bundle.getString("temporaryItem"); restoreTemporarySelection(tempItem); } else super.onRestoreInstanceState(state); } /** * {@inheritDoc} */ @Override public void setVisibility(int visibility) { super.setVisibility(visibility); // When going from state gone to visible with a temporary item selected, but the array has // changed (by toggling FLAG_MORE_TIME), the position is somehow reset by the system, so we // need to reselect the temporary item (even if it was already selected). // This is merely a workaround as I can't find a better solution. if(visibility == VISIBLE) { reselectTemporaryItem = false; PickerSpinnerAdapter adapter = (PickerSpinnerAdapter) getAdapter(); int count = adapter.getCount(); // check whether we have the temporary item selected if(getSelectedItemPosition() == count) { // get the temp item from the adapter to reselect it later: TwinTextItem tempItem = null; try { tempItem = adapter.getItem(count); } catch (IndexOutOfBoundsException e) { Log.d("PickerSpinner", "SetVisibility: Couldn't get temporary item from adapter, aborting workaround"); } // now reselect the temporary item if(tempItem != null) { selectTemporary(tempItem); } } } } /** * Sets the Adapter used to provide the data which backs this Spinner. Needs to be an {@link com.simplicityapks.reminderdatepicker.lib.PickerSpinnerAdapter} * to be used with this class. Note that a PickerSpinner automatically creates its own adapter * so you should not need to call this method. * @param adapter The PickerSpinnerAdapter to be used. * @throws IllegalArgumentException If adapter is not a PickerSpinnerAdapter. */ @Override public void setAdapter(SpinnerAdapter adapter) { if(adapter instanceof PickerSpinnerAdapter) super.setAdapter(adapter); else throw new IllegalArgumentException( "adapter must extend PickerSpinnerAdapter to be used with this class"); } /** * {@inheritDoc} */ @Override public void setSelection(int position) { PickerSpinnerAdapter adapter = (PickerSpinnerAdapter) getAdapter(); if(position == adapter.getCount()-1 && adapter.hasFooter()) onFooterClick(); // the footer has been clicked, so don't update the selection else { // remove any previous temporary selection: ((PickerSpinnerAdapter)getAdapter()).selectTemporary(null); reselectTemporaryItem = false; restoreTemporarySelection = false; // check that the selection goes through: interceptSelectionCallbacks.clear(); super.setSelection(position); super.setSelection(position, false); } } /** * Equivalent to {@link #setSelection(int)}, but without calling any onItemSelectedListeners or * checking for footer clicks. */ private void setSelectionQuietly(int position) { // intercept the callback here: interceptSelectionCallbacks.add(position); superSetSelection(position); } private void superSetSelection(int position) { super.setSelection(position, false); // No idea why both setSelections are needed but it only works with both super.setSelection(position); } /** * Push an item to be selected, but not shown in the dropdown menu. This is similar to calling * setText(item.toString()) if a Spinner had such a method. * @param item The item to select, or null to remove any temporary selection. */ public void selectTemporary(TwinTextItem item) { // if we just want to clear the selection: if(item == null) { setSelection(getLastItemPosition()); // the call is passed on to the adapter in setSelection. return; } PickerSpinnerAdapter adapter = (PickerSpinnerAdapter) getAdapter(); // pass on the call to the adapter (just stores the item): adapter.selectTemporary(item); final int tempItemPosition = adapter.getCount(); if(getSelectedItemPosition() == tempItemPosition) { // this is quite a hack, first reset the position to 0 but intercept the callback, // then redo the selection: setSelectionQuietly(0); } super.setSelection(tempItemPosition); // during initialization the system might check our selected position and reset it, // thus we need to check after the message queue has been settled if (!restoreTemporarySelection) { restoreTemporarySelection = true; post(new Runnable() { @Override public void run() { if (restoreTemporarySelection) { restoreTemporarySelection = false; reselectTemporaryItem = false; final int tempItemPosition = getAdapter().getCount(); if (getSelectedItemPosition() != tempItemPosition) superSetSelection(tempItemPosition); } } }); } } @Override public void setOnItemSelectedListener(final OnItemSelectedListener listener) { super.setOnItemSelectedListener( new OnItemSelectedListener() { @Override public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { if (reselectTemporaryItem) { reselectTemporaryItem = false; final int tempItemPosition = getAdapter().getCount(); if (position != tempItemPosition) setSelectionQuietly(tempItemPosition); } if (interceptSelectionCallbacks.contains(position)) { interceptSelectionCallbacks.remove((Integer) position); } // sometimes during rotation or initialization onItemSelected will be called with the footer selected, catch that else if (!(((PickerSpinnerAdapter) getAdapter()).hasFooter() && position == getLastItemPosition() + 1)) listener.onItemSelected(parent, view, position, id); } @Override public void onNothingSelected(AdapterView<?> parent) { listener.onNothingSelected(parent); } } ); } /** * Gets the position of the last item in the dataset, after which the footer and temporary selection have their index. * @return The last selectable position. */ public int getLastItemPosition() { PickerSpinnerAdapter adapter = (PickerSpinnerAdapter) getAdapter(); return adapter.getCount() - (adapter.hasFooter()? 2 : 1); } /** * Finds a spinner adapter item by its id value (excluding any temporary selection). * @param id The id of the item to search. * @return The specified TwinTextItem, or null if no item with the given id was found. */ public @Nullable TwinTextItem getAdapterItemById(int id) { return ((PickerSpinnerAdapter) getAdapter()).getItemById(id); } /** * Finds a spinner item's position in the data set by its id value (excluding any temporary selection). * @param id The id of the item to search. * @return The position of the specified TwinTextItem, or -1 if no item with the given id was found. */ public int getAdapterItemPosition(int id) { return ((PickerSpinnerAdapter) getAdapter()).getItemPosition(id); } /** * Adds the item to the adapter's data set and takes care of handling selection changes. * Always call this method instead of getAdapter().add(). * @param item The item to insert. */ public void addAdapterItem(TwinTextItem item) { insertAdapterItem(item, getLastItemPosition()+1); } /** * Inserts the item at the specified index into the adapter's data set and takes care of handling selection changes. * Always call this method instead of getAdapter().insert(). * @param item The item to insert. * @param index The index where it'll be at. */ public void insertAdapterItem(TwinTextItem item, int index) { int selection = getSelectedItemPosition(); Object selectedItem = getSelectedItem(); ((PickerSpinnerAdapter) getAdapter()).insert(item, index); // select the new item if there was an equal temporary item selected if(selectedItem.equals(item)) setSelectionQuietly(index); // otherwise keep track when inserting above the selection else if(index <= selection) setSelectionQuietly(selection+1); } /** * Removes the specified item from the adapter and takes care of handling selection changes. * Always call this method instead of getAdapter().remove(). * Note that if you remove the selected item here, it will just reselect the next one instead of * creating a temporary item containing the current selection. * @param index The index of the item to be removed. */ public void removeAdapterItemAt(int index) { PickerSpinnerAdapter adapter = (PickerSpinnerAdapter) getAdapter(); int count = adapter.getCount(); int selection = getSelectedItemPosition(); // check which item will be removed: if(index == count) // temporary selection selectTemporary(null); else if (index == count-1 && adapter.hasFooter()) { // footer if(selection == count) setSelectionQuietly(selection - 1); adapter.setFooter(null); } else { // a normal item // keep the right selection in either of these cases: if(index == selection) { // we delete the selected item and if(index == getLastItemPosition()) // it is the last real item setSelection(selection - 1); else { // we need to reselect the current item // (this is not guaranteed to fire a selection callback when multiple operations // modify the dataset, so it is a lot better to first select the item you want // to have selected, best by overriding this method in your subclass). setSelectionQuietly(index==0 && count>1? 1 : 0); setSelection(selection); } } else if(index < selection && selection!=count) // we remove an item above it setSelectionQuietly(selection - 1); adapter.remove(adapter.getItem(index)); if(selection == count) { // we have a temporary item selected reselectTemporaryItem = true; setSelectionQuietly(selection - 1); } } } /** * Removes the specified item(s) from the adapter and takes care of handling selection changes. * Always call this method instead of getAdapter().remove(). * @param id The id of the item(s) to be removed. All items with this id will be removed. * @return True if one or more items with the specified id were found and removed, false otherwise. */ public boolean removeAdapterItemById(int id) { PickerSpinnerAdapter adapter = (PickerSpinnerAdapter) getAdapter(); boolean result = false; for(int index = adapter.getCount()-1; index >= 0; index--) { TwinTextItem item = adapter.getItem(index); if(item.getId() == id) { removeAdapterItemAt(index); result = true; } } return result; } /** * Gets the default list of items to be inflated into the Spinner, will be called once on * initializing the Spinner. Should use lazy initialization in inherited classes. * @return The List of Objects whose toString() method will be called for the items, or null. */ public abstract List<TwinTextItem> getSpinnerItems(); /** * Gets the CharSequence to be shown as footer in the drop down menu. * @return The footer, or null to disable showing it. */ public abstract CharSequence getFooter(); /** * Built-in listener for clicks on the footer. Note that the footer will not replace the * selection and you still need a separate OnItemSelectedListener. */ public abstract void onFooterClick(); /** * Called to restore a previously saved temporary selection. The given codeString has been saved * using the toString() method on the TwinTextItem. This method should ideally only call * {@link #selectTemporary(TwinTextItem)} with a new TwinTextItem parsed from the codeString. * @param codeString The raw String saved from the item's toString() method. */ protected abstract void restoreTemporarySelection(String codeString); /** * */ protected ArrayList<TwinTextItem> getItemsFromXml(@XmlRes int xmlResource) throws XmlPullParserException, IOException { final Resources res = getResources(); XmlResourceParser parser = res.getXml(xmlResource); ArrayList<TwinTextItem> items = new ArrayList<>(); int eventType; while((eventType = parser.next()) != XmlPullParser.END_DOCUMENT) { if(eventType == XmlPullParser.START_TAG) { // call our subclass to parse the correct item TwinTextItem item = parseItemFromXmlTag(parser); if(item != null) items.add(item); } } return items; } /** * Override this method in your spinner, returning your specific item parsed from the given xml parser at the current tag. * Do not call parser.next() in here! */ protected @Nullable TwinTextItem parseItemFromXmlTag(@NonNull XmlResourceParser parser) { return null; } }