package com.appboy.ui.inappmessage; import android.app.Activity; import android.os.Build; import android.view.Gravity; import android.view.View; import android.view.ViewTreeObserver; import android.view.animation.Animation; import android.webkit.WebView; import android.widget.FrameLayout; import com.appboy.Constants; import com.appboy.enums.inappmessage.DismissType; import com.appboy.enums.inappmessage.SlideFrom; import com.appboy.models.IInAppMessage; import com.appboy.models.IInAppMessageImmersive; import com.appboy.models.InAppMessageSlideup; import com.appboy.models.MessageButton; import com.appboy.support.AppboyLogger; import com.appboy.ui.inappmessage.listeners.IInAppMessageViewLifecycleListener; import com.appboy.ui.inappmessage.listeners.SimpleSwipeDismissTouchListener; import com.appboy.ui.inappmessage.listeners.SwipeDismissTouchListener; import com.appboy.ui.inappmessage.listeners.TouchAwareSwipeDismissTouchListener; import com.appboy.ui.inappmessage.views.AppboyInAppMessageHtmlBaseView; import com.appboy.ui.support.AnimationUtils; import com.appboy.ui.support.ViewUtils; import java.util.List; public class InAppMessageViewWrapper implements IInAppMessageViewWrapper { private static final String TAG = String.format("%s.%s", Constants.APPBOY_LOG_TAG_PREFIX, InAppMessageViewWrapper.class.getName()); private final View mInAppMessageView; private View mClickableInAppMessageView; private View mCloseButton; private List<View> mButtons; private final IInAppMessage mInAppMessage; private final IInAppMessageViewLifecycleListener mInAppMessageViewLifecycleListener; private final Animation mOpeningAnimation; private final Animation mClosingAnimation; private Runnable mDismissRunnable; private boolean mIsAnimatingClose; /** * Constructor for base and slideup view wrappers. Adds click listeners to the in-app message view and * adds swipe functionality to slideup in-app messages. * * @param inAppMessageView In-app message top level view. * @param inAppMessage In-app message model. * @param inAppMessageViewLifecycleListener In-app message lifecycle listener. * @param clickableInAppMessageView View for which click actions apply. Clicking any part of the top level view * outside this view will close the in-app message. In many cases, the clickable * view is the top level view itself. */ public InAppMessageViewWrapper(View inAppMessageView, IInAppMessage inAppMessage, IInAppMessageViewLifecycleListener inAppMessageViewLifecycleListener, Animation openingAnimation, Animation closingAnimation, View clickableInAppMessageView) { mInAppMessageView = inAppMessageView; mInAppMessage = inAppMessage; mInAppMessageViewLifecycleListener = inAppMessageViewLifecycleListener; mIsAnimatingClose = false; if (clickableInAppMessageView != null) { mClickableInAppMessageView = clickableInAppMessageView; } else { mClickableInAppMessageView = mInAppMessageView; } // We only apply the swipe touch listener to slideup in-app message Views on devices running Android version // 12 or higher. Pre-12 devices will have to click to close the slideup in-app message. // Only slideup in-app messages can be swiped. if (Build.VERSION.SDK_INT >= 12 && mInAppMessage instanceof InAppMessageSlideup) { // Adds the swipe listener to the in-app message View. All slideup in-app messages should be dismissible via a swipe // (even auto close slideup in-app messages). SwipeDismissTouchListener.DismissCallbacks dismissCallbacks = createDismissCallbacks(); TouchAwareSwipeDismissTouchListener touchAwareSwipeListener = new TouchAwareSwipeDismissTouchListener(inAppMessageView, null, dismissCallbacks); // We set a custom touch listener that cancel the auto close runnable when touched and adds // a new runnable when the touch ends. touchAwareSwipeListener.setTouchListener(createTouchAwareListener()); mClickableInAppMessageView.setOnTouchListener(touchAwareSwipeListener); } else if (mInAppMessage instanceof InAppMessageSlideup) { mClickableInAppMessageView.setOnTouchListener(getSimpleSwipeListener()); } mOpeningAnimation = openingAnimation; mClosingAnimation = closingAnimation; // Set click listener on clickable in-app message view mClickableInAppMessageView.setOnClickListener(createClickListener()); } /** * Constructor for immersive in-app message view wrappers. Adds listeners to an optional close button and * message button views. * * @param inAppMessageView * @param inAppMessage * @param inAppMessageViewLifecycleListener * @param clickableInAppMessageView * @param buttons List of views corresponding to MessageButton objects stored in the in-app message model object. * These views should map one to one with the MessageButton objects. * @param closeButton */ public InAppMessageViewWrapper(View inAppMessageView, IInAppMessage inAppMessage, IInAppMessageViewLifecycleListener inAppMessageViewLifecycleListener, Animation openingAnimation, Animation closingAnimation, View clickableInAppMessageView, List<View> buttons, View closeButton) { this(inAppMessageView, inAppMessage, inAppMessageViewLifecycleListener, openingAnimation, closingAnimation, clickableInAppMessageView); // Set close button click listener if (closeButton != null) { mCloseButton = closeButton; mCloseButton.setOnClickListener(createCloseInAppMessageClickListener()); } // Set button click listeners if (buttons != null) { mButtons = buttons; for (View button : mButtons) { button.setOnClickListener(createButtonClickListener()); } } } @Override public void open(Activity activity) { // Retrieve the FrameLayout view which will display the in-app message and its height. The // content FrameLayout contains the activity's top-level layout as its first child. final FrameLayout frameLayout = (FrameLayout) activity.getWindow().getDecorView().findViewById(android.R.id.content); int frameLayoutHeight = frameLayout.getHeight(); final int displayHeight = ViewUtils.getDisplayHeight(activity); // If the FrameLayout height is 0, that implies it hasn't been drawn yet. We add a // ViewTreeObserver to wait until its drawn so we can get a proper measurement. if (frameLayoutHeight == 0) { ViewTreeObserver viewTreeObserver = frameLayout.getViewTreeObserver(); if (viewTreeObserver.isAlive()) { viewTreeObserver.addOnGlobalLayoutListener( new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { AppboyLogger.d(TAG, String.format("Detected root view height of %d, display height of %d in onGlobalLayout", frameLayout.getHeight(), displayHeight)); frameLayout.removeView(mInAppMessageView); open(frameLayout, displayHeight); ViewUtils.removeOnGlobalLayoutListenerSafe(frameLayout.getViewTreeObserver(), this); } }); } } else { AppboyLogger.d(TAG, String.format("Detected root view height of %d, display height of %d", frameLayoutHeight, displayHeight)); open(frameLayout, displayHeight); } } private void open(FrameLayout frameLayout, int displayHeight) { mInAppMessageViewLifecycleListener.beforeOpened(mInAppMessageView, mInAppMessage); AppboyLogger.d(TAG, "Adding In-app message view to root FrameLayout."); frameLayout.addView(mInAppMessageView, getLayoutParams(frameLayout, displayHeight)); if (mInAppMessage.getAnimateIn()) { AppboyLogger.d(TAG, "In-app message view will animate into the visible area."); setAndStartAnimation(true); // The afterOpened lifecycle method gets called when the opening animation ends. } else { AppboyLogger.d(TAG, "In-app message view will be placed instantly into the visible area."); // There is no opening animation, so we call the afterOpened lifecycle method immediately. if (mInAppMessage.getDismissType() == DismissType.AUTO_DISMISS) { addDismissRunnable(); } mInAppMessageView.setFocusableInTouchMode(true); mInAppMessageView.requestFocus(); announceForAccessibilityIfNecessary(); mInAppMessageViewLifecycleListener.afterOpened(mInAppMessageView, mInAppMessage); } } private void announceForAccessibilityIfNecessary() { if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN) { if (mInAppMessageView instanceof IInAppMessageImmersiveView) { mInAppMessageView.announceForAccessibility(mInAppMessage.getMessage()); } else if (mInAppMessageView instanceof AppboyInAppMessageHtmlBaseView) { mInAppMessageView.announceForAccessibility("In-app message displayed."); } } } private FrameLayout.LayoutParams getLayoutParams(FrameLayout frameLayout, int displayHeight) { FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT); if (mInAppMessage instanceof InAppMessageSlideup) { InAppMessageSlideup inAppMessageSlideup = (InAppMessageSlideup) mInAppMessage; layoutParams.gravity = inAppMessageSlideup.getSlideFrom() == SlideFrom.TOP ? Gravity.TOP : Gravity.BOTTOM; } // If the display height is a valid value and equivalent to the FrameLayout height, add a margin // equal to the top visible coordinate to compensate for the status bar. if (displayHeight > 0 && displayHeight == frameLayout.getHeight()) { int topVisibleCoordinate = ViewUtils.getTopVisibleCoordinate(frameLayout); AppboyLogger.d(TAG, String.format("Detected status bar height of %d.", topVisibleCoordinate)); layoutParams.setMargins(0, topVisibleCoordinate, 0, 0); } return layoutParams; } @Override public void close() { mInAppMessageView.removeCallbacks(mDismissRunnable); mInAppMessageViewLifecycleListener.beforeClosed(mInAppMessageView, mInAppMessage); if (mInAppMessage.getAnimateOut()) { mIsAnimatingClose = true; setAndStartAnimation(false); } else { closeInAppMessageView(); } } @Override public View getInAppMessageView() { return mInAppMessageView; } @Override public IInAppMessage getInAppMessage() { return mInAppMessage; } @Override public boolean getIsAnimatingClose() { return mIsAnimatingClose; } private View.OnClickListener createClickListener() { return new View.OnClickListener() { @Override public void onClick(View view) { // The onClicked lifecycle method is called and it can be used to turn off the close animation. // Full and modal in-app messages can only be clicked directly when they do not contain buttons. // Slideup in-app messages are always clickable. if (mInAppMessage instanceof IInAppMessageImmersive) { IInAppMessageImmersive inAppMessageImmersive = (IInAppMessageImmersive) mInAppMessage; if (inAppMessageImmersive.getMessageButtons() == null || inAppMessageImmersive.getMessageButtons().size() == 0) { mInAppMessageViewLifecycleListener.onClicked(new InAppMessageCloser(InAppMessageViewWrapper.this), mInAppMessageView, mInAppMessage); } } else { mInAppMessageViewLifecycleListener.onClicked(new InAppMessageCloser(InAppMessageViewWrapper.this), mInAppMessageView, mInAppMessage); } } }; } private View.OnClickListener createButtonClickListener() { return new View.OnClickListener() { @Override public void onClick(View view) { // The onClicked lifecycle method is called and it can be used to turn off the close animation. MessageButton messageButton; IInAppMessageImmersive inAppMessageImmersive = (IInAppMessageImmersive) mInAppMessage; for (int i = 0; i < mButtons.size(); i++) { if (view.getId() == mButtons.get(i).getId()) { messageButton = inAppMessageImmersive.getMessageButtons().get(i); mInAppMessageViewLifecycleListener.onButtonClicked(new InAppMessageCloser(InAppMessageViewWrapper.this), messageButton, inAppMessageImmersive); return; } } } }; } private View.OnClickListener createCloseInAppMessageClickListener() { return new View.OnClickListener() { @Override public void onClick(View view) { AppboyInAppMessageManager.getInstance().hideCurrentlyDisplayingInAppMessage(true); } }; } private void addDismissRunnable() { if (mDismissRunnable == null) { mDismissRunnable = new Runnable() { @Override public void run() { AppboyInAppMessageManager.getInstance().hideCurrentlyDisplayingInAppMessage(true); } }; mInAppMessageView.postDelayed(mDismissRunnable, mInAppMessage.getDurationInMilliseconds()); } } private SwipeDismissTouchListener.DismissCallbacks createDismissCallbacks() { return new SwipeDismissTouchListener.DismissCallbacks() { @Override public boolean canDismiss(Object token) { return true; } @Override public void onDismiss(View view, Object token) { mInAppMessage.setAnimateOut(false); AppboyInAppMessageManager.getInstance().hideCurrentlyDisplayingInAppMessage(true); } }; } private TouchAwareSwipeDismissTouchListener.ITouchListener createTouchAwareListener() { return new TouchAwareSwipeDismissTouchListener.ITouchListener() { @Override public void onTouchStartedOrContinued() { mInAppMessageView.removeCallbacks(mDismissRunnable); } @Override public void onTouchEnded() { if (mInAppMessage.getDismissType() == DismissType.AUTO_DISMISS) { addDismissRunnable(); } } }; } /** * Instantiates and executes the correct animation for the current in-app message. Slideup-type * messages slide in from the top or bottom of the view. Other in-app messages fade in * and out of view. * * @param opening */ private void setAndStartAnimation(boolean opening) { Animation animation; if (opening) { animation = mOpeningAnimation; } else { animation = mClosingAnimation; } animation.setAnimationListener(createAnimationListener(opening)); mInAppMessageView.clearAnimation(); mInAppMessageView.setAnimation(animation); animation.startNow(); // We need to explicitly call invalidate on Gingerbread, otherwise the animation won't start :( mInAppMessageView.invalidate(); } private Animation.AnimationListener createAnimationListener(boolean opening) { if (opening) { return new Animation.AnimationListener() { @Override public void onAnimationStart(Animation animation) {} // This lifecycle callback has been observed to not be called during slideup animations // on occasion. Do not add any code that *MUST* be executed here. @Override public void onAnimationEnd(Animation animation) { if (mInAppMessage.getDismissType() == DismissType.AUTO_DISMISS) { addDismissRunnable(); } AppboyLogger.d(TAG, "In-app message animated into view."); mInAppMessageView.setFocusableInTouchMode(true); mInAppMessageView.requestFocus(); announceForAccessibilityIfNecessary(); mInAppMessageViewLifecycleListener.afterOpened(mInAppMessageView, mInAppMessage); } @Override public void onAnimationRepeat(Animation animation) {} }; } else { return new Animation.AnimationListener() { @Override public void onAnimationStart(Animation animation) {} @Override public void onAnimationEnd(Animation animation) { mInAppMessageView.clearAnimation(); mInAppMessageView.setVisibility(View.GONE); closeInAppMessageView(); } @Override public void onAnimationRepeat(Animation animation) {} }; } } /** * Adds swipe event handling to the SimpleSwipeDismissTouchListener. * * Used in API levels 11 and below. Detected swipe left and right events * cause the slideup inapp message to animate off the screen in the direction of the swipe. */ private SimpleSwipeDismissTouchListener getSimpleSwipeListener() { return new SimpleSwipeDismissTouchListener(mInAppMessageView.getContext()) { private final long sSwipeAnimationDurationMillis = 400L; @Override public void onSwipeLeft() { animateAndClose(AnimationUtils.createHorizontalAnimation(0, -1, sSwipeAnimationDurationMillis, false)); } @Override public void onSwipeRight() { animateAndClose(AnimationUtils.createHorizontalAnimation(0, 1, sSwipeAnimationDurationMillis, false)); } private void animateAndClose(Animation animation) { mInAppMessageView.clearAnimation(); mInAppMessageView.setAnimation(animation); animation.startNow(); mInAppMessageView.invalidate(); mInAppMessage.setAnimateOut(false); AppboyInAppMessageManager.getInstance().hideCurrentlyDisplayingInAppMessage(true); } }; } /** * Closes the in-app message view. * In this order, the following actions are performed: * <ul> * <li> The view is removed from the parent. </li> * <li> Any WebViews have their {@link WebView#destroy()} methods called. </li> * <li> {@link IInAppMessageViewLifecycleListener#afterClosed(IInAppMessage)} is called. </li> * </ul> */ private void closeInAppMessageView() { AppboyLogger.d(TAG, "Closing in-app message view"); ViewUtils.removeViewFromParent(mInAppMessageView); // In the case of HTML in-app messages, we need to make sure the WebView stops once the in-app message is removed. if (mInAppMessageView instanceof AppboyInAppMessageHtmlBaseView) { final AppboyInAppMessageHtmlBaseView inAppMessageHtmlBaseView = (AppboyInAppMessageHtmlBaseView) mInAppMessageView; if (inAppMessageHtmlBaseView.getMessageWebView() != null) { AppboyLogger.d(TAG, "Called destroy on the AppboyInAppMessageHtmlBaseView WebView"); inAppMessageHtmlBaseView.getMessageWebView().destroy(); } } mInAppMessageViewLifecycleListener.afterClosed(mInAppMessage); } }