// Copyright 2015 The Project Buendia Authors // // Licensed under the Apache License, Version 2.0 (the "License"); you may not // use this file except in compliance with the License. You may obtain a copy // of the License at: http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software distributed // under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES // OR CONDITIONS OF ANY KIND, either express or implied. See the License for // specific language governing permissions and limitations under the License. package org.projectbuendia.client.ui; import android.content.Context; import android.content.res.Resources; import android.os.CountDownTimer; import android.support.annotation.NonNull; import android.support.annotation.StringRes; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.animation.TranslateAnimation; import android.widget.BaseExpandableListAdapter; import android.widget.ExpandableListView; import android.widget.ImageView; import android.widget.TextView; import org.projectbuendia.client.R; import java.util.Collections; import java.util.Map; import java.util.TreeMap; /** * Snackbars provide lightweight feedback about an operation by showing a brief message at the * bottom of the screen. Snackbars can contain an action button with an OnClickListener. * Can be auto dismissed by a time out values in seconds and can be prioritized. */ public class SnackBar { private ViewGroup mTargetParent; private Context mContext; private SnackBarListView mList; private SnackBarListAdapter adapter; private int mMessageId; private static TreeMap<MessageKey, Message> mMessagesList; public SnackBar(ViewGroup parent) { mTargetParent = parent; mContext = parent.getContext(); if (mMessagesList == null) { mMessagesList = new TreeMap<>(); } buildList(); } /** * Show the SnackBar with an slide up animation if hidden. */ public void show() { // TODO: discover why does not animate on the first time if (mList.getVisibility() != View.VISIBLE) { animate(View.VISIBLE); } } /** * Hide the SnackBar with a slide down animation if visible. */ public void hide() { animate(View.GONE); } /** * Play the slide up / down animation depending on the list current visibility. * @param visibility Defines which of the animations will be executed (up or down). Possible * values are {@link View#VISIBLE} or {@link View#GONE} */ private void animate(int visibility) { TranslateAnimation animate = new TranslateAnimation(0, 0, visibility == View.VISIBLE ? mList.getHeight() : 0, visibility == View.GONE ? mList.getHeight() : 0 ); animate.setDuration(700); animate.setFillAfter(true); mList.startAnimation(animate); mList.setVisibility(visibility); } /** * Wrapper to the full message method {@link #message(int, int, View.OnClickListener, int, * boolean, int)} * The message won't have action button, it's priority will be 999, will be dismissible and * won't have timer. * @param message The message String. */ public void message(@StringRes int message) { message(message, 0, null, 999, true, 0); } /** * Wrapper to the full message method {@link #message(int, int, View.OnClickListener, int, * boolean, int)} * The message won't have action button, will be dismissible and won't have timer. * @param message The message String resource id. * @param priority The priority of the message. The param is a int and the lower the number the * higher priority the message has. 0 is the highest. */ public void message(@StringRes int message, int priority) { message(message, 0, null, priority, true, 0); } /** * Wrapper to the full message method {@link #message(int, int, View.OnClickListener, int, * boolean, int)} * The message will be dismissible and won't have timer. * @param message The message String resource id. * @param actionMessage The resource id of the label for the action button. * @param actionOnClick The View.OnClickListener for the action button. * @param priority The priority of the message. The param is a int and the lower the * number the * higher priority the message has. 0 is the highest. */ public void message(@StringRes int message, @StringRes int actionMessage, View.OnClickListener actionOnClick, int priority) { message(message, actionMessage, actionOnClick, priority, true, 0); } /** * Add to the list and display a new message. This is the method that should be used to * display messages and it's being called by {@link BaseActivity#snackBar}. * @param message The message String resource id. * @param actionMessage The resource id of the label for the action button. * @param actionOnClick The View.OnClickListener for the action button. * @param priority The priority of the message. The param is a int and the lower the * number the * higher priority the message has. 0 is the highest. * @param isDismissible if true the message will have a X button to remove it self from the * list. * @param secondsToTimeOut Number of seconds to message auto dismiss. 0 to never. */ public void message(@StringRes int message, @StringRes int actionMessage, View.OnClickListener actionOnClick, int priority, boolean isDismissible, int secondsToTimeOut) { mMessageId++; MessageKey key = new MessageKey(mMessageId, priority); Message value = new Message(key, message, actionMessage, actionOnClick, isDismissible); addToQueueWithoutDuplicate(key, value); adapter.notifyDataSetChanged(); if (secondsToTimeOut > 0) { setTimer(key, secondsToTimeOut); } if (mMessagesList.size() == 0) { hide(); } else { show(); } } /** * Checks if a message has duplicate */ private void addToQueueWithoutDuplicate(MessageKey key, Message value) { Message existingMessage = getMessage(value.message); if (existingMessage != null) { mMessagesList.remove(existingMessage.key); } mMessagesList.put(key, value); } /** * Sets the auto-dismiss timer to the message given it's key and seconds to dismiss. * @param key The message key. * @param seconds Seconds until dismiss. */ private void setTimer(final MessageKey key, int seconds) { int limit = seconds*1000; new CountDownTimer(limit, limit) { @Override public void onTick(long millisUntilFinished) { } @Override public void onFinish() { mMessagesList.remove(key); adapter.notifyDataSetChanged(); } }.start(); } /** * Programmatically dismiss multiple messages by an array of ids. * @param id The message id array. */ public void dismiss(int[] id) { boolean changed = false; for (int i = 0; i < id.length; i++) { MessageKey key = getKey(id[i]); if (key != null) { mMessagesList.remove(key); changed = true; } } if (changed) { adapter.notifyDataSetChanged(); } } /** * Programmatically dismiss a message by it's id. * @param id The message id. */ public void dismiss(@StringRes int id) { dismiss(getKey(id)); } /** * Programmatically dismiss a message by a MessageKey Object * @param key The message Key. */ public void dismiss(MessageKey key) { mMessagesList.remove(key); adapter.notifyDataSetChanged(); } /** * Find message Key by it's id value. * @param message The StringRes of the message. * @return The MessageKey of the message. */ public MessageKey getKey(@StringRes int message) { MessageKey theKey = null; for (Map.Entry<MessageKey, Message> entry : mMessagesList.entrySet()) { MessageKey key = entry.getKey(); Message value = entry.getValue(); if (value.message == message) { theKey = key; break; } } return theKey; } public Message getMessage(@StringRes int message){ Message theMessage = null; for (Map.Entry<MessageKey, Message> entry : mMessagesList.entrySet()) { Message value = entry.getValue(); if (value.message == message) { theMessage = value; } } return theMessage; } /** * Initiates the SnackBar ExpandableListView. */ private void buildList() { mList = new SnackBarListView(mContext); mList.setId(R.id.snackbar); setListAppearance(); adapter = new SnackBarListAdapter(mContext); mList.setAdapter(adapter); mTargetParent.addView(mList); if (mMessagesList.size() > 0) { show(); } else { hide(); } } /** * Sets the SnackBar ExpandableListView appearance. */ private void setListAppearance() { mList.setDivider(null); mList.setChildDivider(null); } /** * The Custom ExpansibleListView used by the SnackBar */ private final class SnackBarListView extends ExpandableListView { public SnackBarListView(Context context) { super(context); } /** * Updates the SnackBar with the current messages. * @param visibility */ @Override protected void onWindowVisibilityChanged(int visibility) { super.onWindowVisibilityChanged(visibility); adapter.notifyDataSetChanged(); } } /** * The SnackBar ExpandableListView Adapter responsible to handling the message data. */ private final class SnackBarListAdapter extends BaseExpandableListAdapter { private LayoutInflater mInflater; public SnackBarListAdapter(Context context) { mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); } @Override public int getGroupCount() { return (mMessagesList.size() == 0) ? 0 : 1; } @Override public int getChildrenCount(int groupPosition) { return mMessagesList.size() - 1; } @Override public Object getGroup(int groupPosition) { return mMessagesList.values().toArray()[groupPosition]; } @Override public Object getChild(int groupPosition, int childPosition) { return mMessagesList.values().toArray()[childPosition + 1]; } @Override public long getGroupId(int groupPosition) { return groupPosition; } @Override public long getChildId(int groupPosition, int childPosition) { return childPosition; } @Override public boolean hasStableIds() { return false; } @Override public View getGroupView(int groupPosition, boolean isExpanded, View convertView, ViewGroup parent) { View header = getView(convertView, parent, groupPosition); TextView count = (TextView) header.findViewById(R.id.snackbar_count); View indicator = header.findViewById(R.id.snackbar_indicator); if (mMessagesList.size() > 1) { indicator.setVisibility(View.VISIBLE); count.setText(String.valueOf(mMessagesList.size())); count.setVisibility(View.VISIBLE); } else { indicator.setVisibility(View.INVISIBLE); count.setVisibility(View.INVISIBLE); } return header; } @Override public View getChildView(int groupPosition, int childPosition, boolean isLastChild, View convertView, ViewGroup parent) { return getView(convertView, parent, (childPosition + 1)); } @Override public boolean isChildSelectable(int groupPosition, int childPosition) { return true; } /** * This method is being called by {@code #getChildView} and {@code #getGroupView} to * customize the list items depending on the message specifications. * It add the click handlers to the buttons or hide them if they aren't being used. * @param newView the inflated view by {@code #getChildView} and {@code #getGroupView} */ private View getView(View newView, ViewGroup parent, int position) { Object[] messagesArray = mMessagesList.values().toArray(); if (newView == null) { newView = mInflater.inflate(R.layout.snackbar_item, parent, false); } if ((position >= 0) && (position < messagesArray.length)) { final Message m = (Message) messagesArray[position]; Resources res = mContext.getResources(); String messageString = res.getString(m.message); TextView message = (TextView) newView.findViewById(R.id.snackbar_message); message.setText(messageString); TextView action = (TextView) newView.findViewById(R.id.snackbar_action); if (m.actionString != 0) { String actionString = res.getString(m.actionString); action.setText(actionString); // Set action handler if (m.actionHandler != null) { action.setOnClickListener(m.actionHandler); action.setVisibility(View.VISIBLE); } } else { action.setVisibility(View.INVISIBLE); } // Set Dismiss handler ImageView dismissButton = (ImageView) newView.findViewById(R.id.snackbar_dismiss); if (m.isDismissible) { dismissButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { dismiss(m.key); } }); dismissButton.setVisibility(View.VISIBLE); } else { dismissButton.setVisibility(View.INVISIBLE); } } return newView; } } /** * The key of the {@code TreeMap} used to storing the messages. * Helps the {@code TreeMap} maintain it's balance using priority as a parameter. */ public class MessageKey implements Comparable<MessageKey> { protected int id; protected int priority; public MessageKey(@StringRes int id, int priority) { this.id = id; this.priority = priority; } /** * Used to TreeMap balancing and ordering by priority but also used by TreeMap get() method. * It matches the key with the same id disregarding it's priority. * The ordering is Priority first, Most recent second. Most recent messages appear on top * unless exists a higher priority message. * @param another The key to compare * @return 0 to equal, > 0 to greater than and < 0 to less than. */ @Override public int compareTo(@NonNull MessageKey another) { int equal = 0; int result; int idCompare = Integer.compare(this.id, another.id); if (idCompare == equal) { result = equal; } else { idCompare = -idCompare; //Reverse the order. int priorityCompare = Integer.compare(this.priority, another.priority); if (priorityCompare == equal) { result = idCompare; } else { result = priorityCompare; } } return result; } } /** * The message information. */ public class Message { protected MessageKey key; protected int message; protected int actionString; protected View.OnClickListener actionHandler; protected boolean isDismissible; public Message(MessageKey key, @StringRes int message, @StringRes int actionString, View.OnClickListener handler, boolean isDismissible) { this.key = key; this.message = message; this.actionString = actionString; this.actionHandler = handler; this.isDismissible = isDismissible; } } }