package com.mopub.common;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.graphics.drawable.StateListDrawable;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.SoundEffectConstants;
import android.view.ViewConfiguration;
import android.widget.FrameLayout;
import com.mopub.common.util.Dips;
import static com.mopub.common.util.Drawables.INTERSTITIAL_CLOSE_BUTTON_NORMAL;
import static com.mopub.common.util.Drawables.INTERSTITIAL_CLOSE_BUTTON_PRESSED;
/**
* CloseableLayout provides a layout class that shows a close button, and allows setting a
* {@link OnCloseListener}. Otherwise CloseableLayout behaves identically to
* {@link FrameLayout}.
*
* Rather than adding a button to the view tree, CloseableLayout is designed to draw the close
* button directly on the canvas and to track MotionEvents on its close region. While
* marginally more efficient, the main benefit to this is that CloseableLayout can function
* exactly as a regular FrameLayout without needing to override addView, removeView,
* removeAllViews, and a host of other methods.
*
* You can hide the close button using {@link #setCloseVisible} and change its position
* using {@link #setClosePosition}.
*/
public class CloseableLayout extends FrameLayout {
public interface OnCloseListener {
void onClose();
}
@VisibleForTesting
static final float CLOSE_BUTTON_SIZE_DP = 30.0f;
static final float CLOSE_REGION_SIZE_DP = 50.0f;
@VisibleForTesting
static final float CLOSE_BUTTON_PADDING_DP = 8.0f;
/**
* Defines a subset of supported gravity combinations for the CloseableLayout. These values
* include the possible values for customClosePosition as defined in the
* <a href="http://www.iab.net/media/file/IAB_MRAID_v2_FINAL.pdf">MRAID 2.0
* specification</a>.
*/
public static enum ClosePosition {
TOP_LEFT(Gravity.TOP | Gravity.LEFT),
TOP_CENTER(Gravity.TOP | Gravity.CENTER_HORIZONTAL),
TOP_RIGHT(Gravity.TOP | Gravity.RIGHT),
CENTER(Gravity.CENTER),
BOTTOM_LEFT(Gravity.BOTTOM | Gravity.LEFT),
BOTTOM_CENTER(Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL),
BOTTOM_RIGHT(Gravity.BOTTOM | Gravity.RIGHT);
private final int mGravity;
ClosePosition(final int mGravity) {
this.mGravity = mGravity;
}
int getGravity() {
return mGravity;
}
}
// Used in onTouchEvent to be lenient about moving outside the close button bounds. This is the
// same pattern used in the Android framework to handle click events.
private final int mTouchSlop;
@Nullable
private OnCloseListener mOnCloseListener;
@NonNull
private final StateListDrawable mCloseDrawable;
@NonNull
private ClosePosition mClosePosition;
private final int mCloseRegionSize; // Size of the touchable close region.
private final int mCloseButtonSize; // Size of the drawn close button.
private final int mCloseButtonPadding;
// Whether we need to recalculate the close bounds on the next draw pass
private boolean mCloseBoundChanged;
// Hang on to our bounds Rects so we don't allocate memory in the draw() method.
private final Rect mClosableLayoutRect = new Rect();
private final Rect mCloseRegionBounds = new Rect();
private final Rect mCloseButtonBounds = new Rect();
private final Rect mInsetCloseRegionBounds = new Rect();
@Nullable
private UnsetPressedState mUnsetPressedState;
public CloseableLayout(@NonNull Context context) {
super(context);
mCloseDrawable = new StateListDrawable();
mClosePosition = ClosePosition.TOP_RIGHT;
mCloseDrawable.addState(SELECTED_STATE_SET,
INTERSTITIAL_CLOSE_BUTTON_PRESSED.createDrawable(context));
mCloseDrawable.addState(EMPTY_STATE_SET,
INTERSTITIAL_CLOSE_BUTTON_NORMAL.createDrawable(context));
mCloseDrawable.setState(EMPTY_STATE_SET);
mCloseDrawable.setCallback(this);
mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
mCloseRegionSize = Dips.asIntPixels(CLOSE_REGION_SIZE_DP, context);
mCloseButtonSize = Dips.asIntPixels(CLOSE_BUTTON_SIZE_DP, context);
mCloseButtonPadding = Dips.asIntPixels(CLOSE_BUTTON_PADDING_DP, context);
setWillNotDraw(false);
}
public void setOnCloseListener(@Nullable OnCloseListener onCloseListener) {
mOnCloseListener = onCloseListener;
}
public void setClosePosition(@NonNull ClosePosition closePosition) {
Preconditions.checkNotNull(closePosition);
mClosePosition = closePosition;
mCloseBoundChanged = true;
invalidate();
}
public void setCloseVisible(boolean visible) {
if (mCloseDrawable.setVisible(visible, false)) {
invalidate(mCloseRegionBounds);
}
}
@Override
protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) {
super.onSizeChanged(width, height, oldWidth, oldHeight);
mCloseBoundChanged = true;
}
@Override
public void draw(@NonNull final Canvas canvas) {
super.draw(canvas);
// Only recalculate the close bounds if they are dirty
if (mCloseBoundChanged) {
mCloseBoundChanged = false;
mClosableLayoutRect.set(0, 0, getWidth(), getHeight());
// Create the bounds for our close regions.
applyCloseRegionBounds(mClosePosition, mClosableLayoutRect, mCloseRegionBounds);
// The inset rect applies padding around the visible closeButton.
mInsetCloseRegionBounds.set(mCloseRegionBounds);
mInsetCloseRegionBounds.inset(mCloseButtonPadding, mCloseButtonPadding);
// The close button sits inside the close region with padding and gravity
// in the same way the close region sits inside the whole ClosableLayout
applyCloseButtonBounds(mClosePosition, mInsetCloseRegionBounds, mCloseButtonBounds);
mCloseDrawable.setBounds(mCloseButtonBounds);
}
// Draw last so that this gets drawn as the top layer. This is also why we override
// draw instead of onDraw.
if (mCloseDrawable.isVisible()) {
mCloseDrawable.draw(canvas);
}
}
public void applyCloseRegionBounds(ClosePosition closePosition, Rect bounds, Rect closeBounds) {
applyCloseBoundsWithSize(closePosition, mCloseRegionSize, bounds, closeBounds);
}
private void applyCloseButtonBounds(ClosePosition closePosition, Rect bounds, Rect outBounds) {
applyCloseBoundsWithSize(closePosition, mCloseButtonSize, bounds, outBounds);
}
private void applyCloseBoundsWithSize(ClosePosition closePosition, final int size, Rect bounds, Rect outBounds) {
Gravity.apply(closePosition.getGravity(), size, size, bounds, outBounds);
}
@Override
public boolean onInterceptTouchEvent(@NonNull final MotionEvent event) {
// See http://developer.android.com/training/gestures/viewgroup.html for details on
// capturing motion events
// Start intercepting touch events only when we see a down event
if (event.getAction() != MotionEvent.ACTION_DOWN) {
return false;
}
// Start intercepting if the down event is in the close bounds. Returning true
// here causes onTouchEvent to get called for all events up until ACTION_CANCEL gets called.
final int x = (int) event.getX();
final int y = (int) event.getY();
return pointInCloseBounds(x, y, 0);
}
@Override
public boolean onTouchEvent(@NonNull MotionEvent event) {
// Stop receiving touch events if we aren't within the bounds, including some slop.
final int x = (int) event.getX();
final int y = (int) event.getY();
if (!pointInCloseBounds(x, y, mTouchSlop)) {
setClosePressed(false);
super.onTouchEvent(event);
return false;
}
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
setClosePressed(true);
break;
case MotionEvent.ACTION_CANCEL:
// Cancelled by a parent
setClosePressed(false);
break;
case MotionEvent.ACTION_UP:
if (isClosePressed()) {
// Delay setting the unpressed state so that the button remains pressed
// at least long enough to respond to the close event.
if (mUnsetPressedState == null) {
mUnsetPressedState = new UnsetPressedState();
}
postDelayed(mUnsetPressedState, ViewConfiguration.getPressedStateDuration());
performClose();
}
break;
}
return true;
}
private void setClosePressed(boolean pressed) {
if (pressed == isClosePressed()) {
return;
}
mCloseDrawable.setState(pressed ? SELECTED_STATE_SET : EMPTY_STATE_SET);
invalidate(mCloseRegionBounds);
}
@VisibleForTesting
boolean isClosePressed() {
return mCloseDrawable.getState() == SELECTED_STATE_SET;
}
@VisibleForTesting
boolean pointInCloseBounds(int x, int y, int slop) {
return x >= mCloseRegionBounds.left - slop
&& y >= mCloseRegionBounds.top - slop
&& x < mCloseRegionBounds.right + slop
&& y < mCloseRegionBounds.bottom + slop;
}
private void performClose() {
playSoundEffect(SoundEffectConstants.CLICK);
if (mOnCloseListener != null) {
mOnCloseListener.onClose();
}
}
/**
* This is a copy of the UnsetPressedState pattern from Android's View.java, which is used
* to unset the pressed state of a button after a delay.
*/
private final class UnsetPressedState implements Runnable {
public void run() {
setClosePressed(false);
}
}
@VisibleForTesting
void setCloseBounds(Rect closeBounds) {
mCloseRegionBounds.set(closeBounds);
}
@VisibleForTesting
Rect getCloseBounds() {
return mCloseRegionBounds;
}
@VisibleForTesting
void setCloseBoundChanged(boolean changed) {
mCloseBoundChanged = changed;
}
@VisibleForTesting
public boolean isCloseVisible() {
return mCloseDrawable.isVisible();
}
}