/*
* Copyright 2012 - 2014 Benjamin Weiss
*
* 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 the specific language governing permissions and
* limitations under the License.
*/
package de.keyboardsurfer.android.widget.crouton;
import android.annotation.TargetApi;
import android.app.Activity;
import android.content.Context;
import android.content.res.Resources;
import android.os.Build;
import android.os.Handler;
import android.os.Message;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.ViewTreeObserver;
import android.view.Window;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityManager;
import android.widget.AdapterView;
import android.widget.FrameLayout;
import android.widget.RelativeLayout;
import java.util.Iterator;
import java.util.Queue;
import java.util.concurrent.LinkedBlockingQueue;
/**
* Manages the lifecycle of {@link de.keyboardsurfer.android.widget.crouton.Crouton}s.
*/
final class Manager extends Handler {
private static final class Messages {
private Messages() { /* no-op */ }
public static final int DISPLAY_CROUTON = 0xc2007;
public static final int ADD_CROUTON_TO_VIEW = 0xc20074dd;
public static final int REMOVE_CROUTON = 0xc2007de1;
}
private static Manager INSTANCE;
private final Queue<Crouton> croutonQueue;
private Manager() {
croutonQueue = new LinkedBlockingQueue<Crouton>();
}
/**
* @return The currently used instance of the {@link de.keyboardsurfer.android.widget.crouton.Manager}.
*/
static synchronized Manager getInstance() {
if (null == INSTANCE) {
INSTANCE = new Manager();
}
return INSTANCE;
}
/**
* Inserts a {@link de.keyboardsurfer.android.widget.crouton.Crouton} to be displayed.
*
* @param crouton
* The {@link de.keyboardsurfer.android.widget.crouton.Crouton} to be displayed.
*/
void add(Crouton crouton) {
croutonQueue.add(crouton);
displayCrouton();
}
/**
* Displays the next {@link de.keyboardsurfer.android.widget.crouton.Crouton} within the queue.
*/
private void displayCrouton() {
if (croutonQueue.isEmpty()) {
return;
}
// First peek whether the Crouton has an activity.
final Crouton currentCrouton = croutonQueue.peek();
// If the activity is null we poll the Crouton off the queue.
if (null == currentCrouton.getActivity()) {
croutonQueue.poll();
}
if (!currentCrouton.isShowing()) {
// Display the Crouton
sendMessage(currentCrouton, Messages.ADD_CROUTON_TO_VIEW);
if (null != currentCrouton.getLifecycleCallback()) {
currentCrouton.getLifecycleCallback().onDisplayed();
}
} else {
sendMessageDelayed(currentCrouton, Messages.DISPLAY_CROUTON, calculateCroutonDuration(currentCrouton));
}
}
private long calculateCroutonDuration(Crouton crouton) {
long croutonDuration = crouton.getConfiguration().durationInMilliseconds;
croutonDuration += crouton.getInAnimation().getDuration();
croutonDuration += crouton.getOutAnimation().getDuration();
return croutonDuration;
}
/**
* Sends a {@link de.keyboardsurfer.android.widget.crouton.Crouton} within a {@link android.os.Message}.
*
* @param crouton
* The {@link de.keyboardsurfer.android.widget.crouton.Crouton} that should be sent.
* @param messageId
* The {@link android.os.Message} id.
*/
private void sendMessage(Crouton crouton, final int messageId) {
final Message message = obtainMessage(messageId);
message.obj = crouton;
sendMessage(message);
}
/**
* Sends a {@link de.keyboardsurfer.android.widget.crouton.Crouton} within a delayed {@link android.os.Message}.
*
* @param crouton
* The {@link de.keyboardsurfer.android.widget.crouton.Crouton} that should be sent.
* @param messageId
* The {@link android.os.Message} id.
* @param delay
* The delay in milliseconds.
*/
private void sendMessageDelayed(Crouton crouton, final int messageId, final long delay) {
Message message = obtainMessage(messageId);
message.obj = crouton;
sendMessageDelayed(message, delay);
}
/*
* (non-Javadoc)
*
* @see android.os.Handler#handleMessage(android.os.Message)
*/
@Override
public void handleMessage(Message message) {
final Crouton crouton = (Crouton) message.obj;
if (null == crouton) {
return;
}
switch (message.what) {
case Messages.DISPLAY_CROUTON: {
displayCrouton();
break;
}
case Messages.ADD_CROUTON_TO_VIEW: {
addCroutonToView(crouton);
break;
}
case Messages.REMOVE_CROUTON: {
removeCrouton(crouton);
if (null != crouton.getLifecycleCallback()) {
crouton.getLifecycleCallback().onRemoved();
}
break;
}
default: {
super.handleMessage(message);
break;
}
}
}
/**
* Adds a {@link de.keyboardsurfer.android.widget.crouton.Crouton} to the {@link android.view.ViewParent} of it's {@link android.app.Activity}.
*
* @param crouton
* The {@link de.keyboardsurfer.android.widget.crouton.Crouton} that should be added.
*/
private void addCroutonToView(final Crouton crouton) {
// don't add if it is already showing
if (crouton.isShowing()) {
return;
}
final View croutonView = crouton.getView();
if (null == croutonView.getParent()) {
ViewGroup.LayoutParams params = croutonView.getLayoutParams();
if (null == params) {
params =
new ViewGroup.MarginLayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
}
// display Crouton in ViewGroup if it has been supplied
if (null != crouton.getViewGroup()) {
final ViewGroup croutonViewGroup = crouton.getViewGroup();
if (shouldAddViewWithoutPosition(croutonViewGroup)) {
croutonViewGroup.addView(croutonView, params);
} else {
croutonViewGroup.addView(croutonView, 0, params);
}
} else {
Activity activity = crouton.getActivity();
if (null == activity || activity.isFinishing()) {
return;
}
handleTranslucentActionBar((ViewGroup.MarginLayoutParams) params, activity);
handleActionBarOverlay((ViewGroup.MarginLayoutParams) params, activity);
activity.addContentView(croutonView, params);
}
}
croutonView.requestLayout(); // This is needed so the animation can use the measured with/height
ViewTreeObserver observer = croutonView.getViewTreeObserver();
if (null != observer) {
observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
@TargetApi(16)
public void onGlobalLayout() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
croutonView.getViewTreeObserver().removeGlobalOnLayoutListener(this);
} else {
croutonView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
if(crouton.getInAnimation() != null) {
croutonView.startAnimation(crouton.getInAnimation());
announceForAccessibilityCompat(crouton.getActivity(), crouton.getText());
if (Configuration.DURATION_INFINITE != crouton.getConfiguration().durationInMilliseconds) {
sendMessageDelayed(crouton, Messages.REMOVE_CROUTON,
crouton.getConfiguration().durationInMilliseconds + crouton.getInAnimation().getDuration());
}
}
}
});
}
}
private boolean shouldAddViewWithoutPosition(ViewGroup croutonViewGroup) {
return croutonViewGroup instanceof FrameLayout || croutonViewGroup instanceof AdapterView ||
croutonViewGroup instanceof RelativeLayout;
}
@TargetApi(19)
private void handleTranslucentActionBar(ViewGroup.MarginLayoutParams params, Activity activity) {
// Translucent status is only available as of Android 4.4 Kit Kat.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
final int flags = activity.getWindow().getAttributes().flags;
final int translucentStatusFlag = WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS;
if ((flags & translucentStatusFlag) == translucentStatusFlag) {
setActionBarMargin(params, activity);
}
}
}
@TargetApi(11)
private void handleActionBarOverlay(ViewGroup.MarginLayoutParams params, Activity activity) {
// ActionBar overlay is only available as of Android 3.0 Honeycomb.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
final boolean flags = activity.getWindow().hasFeature(Window.FEATURE_ACTION_BAR_OVERLAY);
if (flags) {
setActionBarMargin(params, activity);
}
}
}
private void setActionBarMargin(ViewGroup.MarginLayoutParams params, Activity activity) {
final int actionBarContainerId = Resources.getSystem().getIdentifier("action_bar_container", "id", "android");
final View actionBarContainer = activity.findViewById(actionBarContainerId);
// The action bar is present: the app is using a Holo theme.
if (null != actionBarContainer) {
params.topMargin = actionBarContainer.getBottom();
}
}
/**
* Removes the {@link de.keyboardsurfer.android.widget.crouton.Crouton}'s view after it's display
* durationInMilliseconds.
*
* @param crouton
* The {@link de.keyboardsurfer.android.widget.crouton.Crouton} added to a {@link android.view.ViewGroup} and should be
* removed.
*/
protected void removeCrouton(Crouton crouton) {
View croutonView = crouton.getView();
ViewGroup croutonParentView = (ViewGroup) croutonView.getParent();
if (null != croutonParentView) {
croutonView.startAnimation(crouton.getOutAnimation());
// Remove the Crouton from the queue.
Crouton removed = croutonQueue.poll();
// Remove the crouton from the view's parent.
croutonParentView.removeView(croutonView);
if (null != removed) {
removed.detachActivity();
removed.detachViewGroup();
if (null != removed.getLifecycleCallback()) {
removed.getLifecycleCallback().onRemoved();
}
removed.detachLifecycleCallback();
}
// Send a message to display the next crouton but delay it by the out
// animation duration to make sure it finishes
sendMessageDelayed(crouton, Messages.DISPLAY_CROUTON, crouton.getOutAnimation().getDuration());
}
}
/**
* Removes a {@link de.keyboardsurfer.android.widget.crouton.Crouton} immediately, even when it's currently being
* displayed.
*
* @param crouton
* The {@link de.keyboardsurfer.android.widget.crouton.Crouton} that should be removed.
*/
void removeCroutonImmediately(Crouton crouton) {
// if Crouton has already been displayed then it may not be in the queue (because it was popped).
// This ensures the displayed Crouton is removed from its parent immediately, whether another instance
// of it exists in the queue or not.
// Note: crouton.isShowing() is false here even if it really is showing, as croutonView object in
// Crouton seems to be out of sync with reality!
if (null != crouton.getActivity() && null != crouton.getView() && null != crouton.getView().getParent()) {
((ViewGroup) crouton.getView().getParent()).removeView(crouton.getView());
// remove any messages pending for the crouton
removeAllMessagesForCrouton(crouton);
}
// remove any matching croutons from queue
final Iterator<Crouton> croutonIterator = croutonQueue.iterator();
while (croutonIterator.hasNext()) {
final Crouton c = croutonIterator.next();
if (c.equals(crouton) && (null != c.getActivity())) {
// remove the crouton from the content view
removeCroutonFromViewParent(crouton);
// remove any messages pending for the crouton
removeAllMessagesForCrouton(c);
// remove the crouton from the queue
croutonIterator.remove();
// we have found our crouton so just break
break;
}
}
}
/**
* Removes all {@link de.keyboardsurfer.android.widget.crouton.Crouton}s from the queue.
*/
void clearCroutonQueue() {
removeAllMessages();
// remove any views that may already have been added to the activity's
// content view
for (Crouton crouton : croutonQueue) {
removeCroutonFromViewParent(crouton);
}
croutonQueue.clear();
}
/**
* Removes all {@link de.keyboardsurfer.android.widget.crouton.Crouton}s for the provided activity. This will remove
* crouton from {@link android.app.Activity}s content view immediately.
*/
void clearCroutonsForActivity(Activity activity) {
Iterator<Crouton> croutonIterator = croutonQueue.iterator();
while (croutonIterator.hasNext()) {
Crouton crouton = croutonIterator.next();
if ((null != crouton.getActivity()) && crouton.getActivity().equals(activity)) {
// remove the crouton from the content view
removeCroutonFromViewParent(crouton);
removeAllMessagesForCrouton(crouton);
// remove the crouton from the queue
croutonIterator.remove();
}
}
}
private void removeCroutonFromViewParent(Crouton crouton) {
if (crouton.isShowing()) {
ViewGroup parent = (ViewGroup) crouton.getView().getParent();
if (null != parent) {
parent.removeView(crouton.getView());
}
}
}
private void removeAllMessages() {
removeMessages(Messages.ADD_CROUTON_TO_VIEW);
removeMessages(Messages.DISPLAY_CROUTON);
removeMessages(Messages.REMOVE_CROUTON);
}
private void removeAllMessagesForCrouton(Crouton crouton) {
removeMessages(Messages.ADD_CROUTON_TO_VIEW, crouton);
removeMessages(Messages.DISPLAY_CROUTON, crouton);
removeMessages(Messages.REMOVE_CROUTON, crouton);
}
/**
* Generates and dispatches an SDK-specific spoken announcement.
* <p>
* For backwards compatibility, we're constructing an event from scratch
* using the appropriate event type. If your application only targets SDK
* 16+, you can just call View.announceForAccessibility(CharSequence).
* </p>
* <p/>
* note: AccessibilityManager is only available from API lvl 4.
* <p/>
* Adapted from https://http://eyes-free.googlecode.com/files/accessibility_codelab_demos_v2_src.zip
* via https://github.com/coreform/android-formidable-validation
*
* @param context
* Used to get {@link android.view.accessibility.AccessibilityManager}
* @param text
* The text to announce.
*/
public static void announceForAccessibilityCompat(Context context, CharSequence text) {
if (Build.VERSION.SDK_INT >= 4) {
AccessibilityManager accessibilityManager = null;
if (null != context) {
accessibilityManager = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
}
if (null == accessibilityManager || !accessibilityManager.isEnabled()) {
return;
}
// Prior to SDK 16, announcements could only be made through FOCUSED
// events. Jelly Bean (SDK 16) added support for speaking text verbatim
// using the ANNOUNCEMENT event type.
final int eventType;
if (Build.VERSION.SDK_INT < 16) {
eventType = AccessibilityEvent.TYPE_VIEW_FOCUSED;
} else {
eventType = AccessibilityEvent.TYPE_ANNOUNCEMENT;
}
// Construct an accessibility event with the minimum recommended
// attributes. An event without a class name or package may be dropped.
final AccessibilityEvent event = AccessibilityEvent.obtain(eventType);
event.getText().add(text);
event.setClassName(Manager.class.getName());
event.setPackageName(context.getPackageName());
// Sends the event directly through the accessibility manager. If your
// application only targets SDK 14+, you should just call
// getParent().requestSendAccessibilityEvent(this, event);
accessibilityManager.sendAccessibilityEvent(event);
}
}
@Override
public String toString() {
return "Manager{" +
"croutonQueue=" + croutonQueue +
'}';
}
}