mTitleStack = new Stack<>();
+
+ /** The cursor position. Between (0,0) and (mRows-1, mColumns-1). */
+ private int mCursorRow, mCursorCol;
+
+ private int mCursorStyle = CURSOR_STYLE_BLOCK;
+
+ /** The number of character rows and columns in the terminal screen. */
+ public int mRows, mColumns;
+
+ /** The normal screen buffer. Stores the characters that appear on the screen of the emulated terminal. */
+ private final TerminalBuffer mMainBuffer;
+ /**
+ * The alternate screen buffer, exactly as large as the display and contains no additional saved lines (so that when
+ * the alternate screen buffer is active, you cannot scroll back to view saved lines).
+ *
+ * See http://www.xfree86.org/current/ctlseqs.html#The%20Alternate%20Screen%20Buffer
+ */
+ final TerminalBuffer mAltBuffer;
+ /** The current screen buffer, pointing at either {@link #mMainBuffer} or {@link #mAltBuffer}. */
+ private TerminalBuffer mScreen;
+
+ /** The terminal session this emulator is bound to. */
+ private final TerminalOutput mSession;
+
+ /** Keeps track of the current argument of the current escape sequence. Ranges from 0 to MAX_ESCAPE_PARAMETERS-1. */
+ private int mArgIndex;
+ /** Holds the arguments of the current escape sequence. */
+ private final int[] mArgs = new int[MAX_ESCAPE_PARAMETERS];
+
+ /** Holds OSC and device control arguments, which can be strings. */
+ private final StringBuilder mOSCOrDeviceControlArgs = new StringBuilder();
+
+ /**
+ * True if the current escape sequence should continue, false if the current escape sequence should be terminated.
+ * Used when parsing a single character.
+ */
+ private boolean mContinueSequence;
+
+ /** The current state of the escape sequence state machine. One of the ESC_* constants. */
+ private int mEscapeState;
+
+ private final SavedScreenState mSavedStateMain = new SavedScreenState();
+ private final SavedScreenState mSavedStateAlt = new SavedScreenState();
+
+ /** http://www.vt100.net/docs/vt102-ug/table5-15.html */
+ private boolean mUseLineDrawingG0, mUseLineDrawingG1, mUseLineDrawingUsesG0 = true;
+
+ /**
+ * @see TerminalEmulator#mapDecSetBitToInternalBit(int)
+ */
+ private int mCurrentDecSetFlags, mSavedDecSetFlags;
+
+ /**
+ * If insert mode (as opposed to replace mode) is active. In insert mode new characters are inserted, pushing
+ * existing text to the right. Characters moved past the right margin are lost.
+ */
+ private boolean mInsertMode;
+
+ /** An array of tab_switcher stops. mTabStop[i] is true if there is a tab_switcher stop set for column i. */
+ private boolean[] mTabStop;
+
+ /**
+ * Top margin of screen for scrolling ranges from 0 to mRows-2. Bottom margin ranges from mTopMargin + 2 to mRows
+ * (Defines the first row after the scrolling region). Left/right margin in [0, mColumns].
+ */
+ private int mTopMargin, mBottomMargin, mLeftMargin, mRightMargin;
+
+ /**
+ * If the next character to be emitted will be automatically wrapped to the next line. Used to disambiguate the case
+ * where the cursor is positioned on the last column (mColumns-1). When standing there, a written character will be
+ * output in the last column, the cursor not moving but this flag will be set. When outputting another character
+ * this will move to the next line.
+ */
+ private boolean mAboutToAutoWrap;
+
+ /**
+ * Current foreground and background colors. Can either be a color index in [0,259] or a truecolor (24-bit) value.
+ * For a 24-bit value the top byte (0xff000000) is set.
+ *
+ * @see TextStyle
+ */
+ int mForeColor, mBackColor;
+
+ /** Current {@link TextStyle} effect. */
+ private int mEffect;
+
+ /**
+ * The number of scrolled lines since last calling {@link #clearScrollCounter()}. Used for moving selection up along
+ * with the scrolling text.
+ */
+ private int mScrollCounter = 0;
+
+ private byte mUtf8ToFollow, mUtf8Index;
+ private final byte[] mUtf8InputBuffer = new byte[4];
+
+ public final TerminalColors mColors = new TerminalColors();
+
+ private boolean isDecsetInternalBitSet(int bit) {
+ return (mCurrentDecSetFlags & bit) != 0;
+ }
+
+ private void setDecsetinternalBit(int internalBit, boolean set) {
+ if (set) {
+ // The mouse modes are mutually exclusive.
+ if (internalBit == DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE) {
+ setDecsetinternalBit(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT, false);
+ } else if (internalBit == DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT) {
+ setDecsetinternalBit(DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE, false);
+ }
+ }
+ if (set) {
+ mCurrentDecSetFlags |= internalBit;
+ } else {
+ mCurrentDecSetFlags &= ~internalBit;
+ }
+ }
+
+ static int mapDecSetBitToInternalBit(int decsetBit) {
+ switch (decsetBit) {
+ case 1:
+ return DECSET_BIT_APPLICATION_CURSOR_KEYS;
+ case 5:
+ return DECSET_BIT_REVERSE_VIDEO;
+ case 6:
+ return DECSET_BIT_ORIGIN_MODE;
+ case 7:
+ return DECSET_BIT_AUTOWRAP;
+ case 25:
+ return DECSET_BIT_SHOWING_CURSOR;
+ case 66:
+ return DECSET_BIT_APPLICATION_KEYPAD;
+ case 69:
+ return DECSET_BIT_LEFTRIGHT_MARGIN_MODE;
+ case 1000:
+ return DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE;
+ case 1002:
+ return DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT;
+ case 1004:
+ return DECSET_BIT_SEND_FOCUS_EVENTS;
+ case 1006:
+ return DECSET_BIT_MOUSE_PROTOCOL_SGR;
+ case 2004:
+ return DECSET_BIT_BRACKETED_PASTE_MODE;
+ default:
+ return -1;
+ // throw new IllegalArgumentException("Unsupported decset: " + decsetBit);
+ }
+ }
+
+ public TerminalEmulator(TerminalOutput session, int columns, int rows, int transcriptRows) {
+ mSession = session;
+ mScreen = mMainBuffer = new TerminalBuffer(columns, transcriptRows, rows);
+ mAltBuffer = new TerminalBuffer(columns, rows, rows);
+ mRows = rows;
+ mColumns = columns;
+ mTabStop = new boolean[mColumns];
+ reset();
+ }
+
+ public TerminalBuffer getScreen() {
+ return mScreen;
+ }
+
+ public boolean isAlternateBufferActive() {
+ return mScreen == mAltBuffer;
+ }
+
+ /**
+ * @param mouseButton one of the MOUSE_* constants of this class.
+ */
+ public void sendMouseEvent(int mouseButton, int column, int row, boolean pressed) {
+ if (mouseButton == MOUSE_LEFT_BUTTON_MOVED && !isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT)) {
+ // Do not send tracking.
+ } else if (isDecsetInternalBitSet(DECSET_BIT_MOUSE_PROTOCOL_SGR)) {
+ mSession.write(String.format("\033[<%d;%d;%d" + (pressed ? 'M' : 'm'), mouseButton, column, row));
+ } else {
+ mouseButton = pressed ? mouseButton : 3; // 3 for release of all buttons.
+ // Clip to screen, and clip to the limits of 8-bit data.
+ boolean out_of_bounds = column < 1 || row < 1 || column > mColumns || row > mRows || column > 255 - 32 || row > 255 - 32;
+ if (!out_of_bounds) {
+ byte[] data = {'\033', '[', 'M', (byte) (32 + mouseButton), (byte) (32 + column), (byte) (32 + row)};
+ mSession.write(data, 0, data.length);
+ }
+ }
+ }
+
+ public void resize(int columns, int rows) {
+ if (mRows == rows && mColumns == columns) {
+ return;
+ } else if (columns < 2 || rows < 2) {
+ throw new IllegalArgumentException("rows=" + rows + ", columns=" + columns);
+ }
+
+ if (mRows != rows) {
+ mRows = rows;
+ mTopMargin = 0;
+ mBottomMargin = mRows;
+ }
+ if (mColumns != columns) {
+ int oldColumns = mColumns;
+ mColumns = columns;
+ boolean[] oldTabStop = mTabStop;
+ mTabStop = new boolean[mColumns];
+ setDefaultTabStops();
+ int toTransfer = Math.min(oldColumns, columns);
+ System.arraycopy(oldTabStop, 0, mTabStop, 0, toTransfer);
+ mLeftMargin = 0;
+ mRightMargin = mColumns;
+ }
+
+ resizeScreen();
+ }
+
+ private void resizeScreen() {
+ final int[] cursor = {mCursorCol, mCursorRow};
+ int newTotalRows = (mScreen == mAltBuffer) ? mRows : mMainBuffer.mTotalRows;
+ mScreen.resize(mColumns, mRows, newTotalRows, cursor, getStyle(), isAlternateBufferActive());
+ mCursorCol = cursor[0];
+ mCursorRow = cursor[1];
+ }
+
+ public int getCursorRow() {
+ return mCursorRow;
+ }
+
+ public int getCursorCol() {
+ return mCursorCol;
+ }
+
+ /** {@link #CURSOR_STYLE_BAR}, {@link #CURSOR_STYLE_BLOCK} or {@link #CURSOR_STYLE_UNDERLINE} */
+ public int getCursorStyle() {
+ return mCursorStyle;
+ }
+
+ public boolean isReverseVideo() {
+ return isDecsetInternalBitSet(DECSET_BIT_REVERSE_VIDEO);
+ }
+
+ public boolean isShowingCursor() {
+ return isDecsetInternalBitSet(DECSET_BIT_SHOWING_CURSOR);
+ }
+
+ public boolean isKeypadApplicationMode() {
+ return isDecsetInternalBitSet(DECSET_BIT_APPLICATION_KEYPAD);
+ }
+
+ public boolean isCursorKeysApplicationMode() {
+ return isDecsetInternalBitSet(DECSET_BIT_APPLICATION_CURSOR_KEYS);
+ }
+
+ /** If mouse events are being sent as escape codes to the terminal. */
+ public boolean isMouseTrackingActive() {
+ return isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE) || isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT);
+ }
+
+ private void setDefaultTabStops() {
+ for (int i = 0; i < mColumns; i++)
+ mTabStop[i] = (i & 7) == 0 && i != 0;
+ }
+
+ /**
+ * Accept bytes (typically from the pseudo-teletype) and process them.
+ *
+ * @param buffer a byte array containing the bytes to be processed
+ * @param length the number of bytes in the array to process
+ */
+ public void append(byte[] buffer, int length) {
+ for (int i = 0; i < length; i++)
+ processByte(buffer[i]);
+ }
+
+ private void processByte(byte byteToProcess) {
+ if (mUtf8ToFollow > 0) {
+ if ((byteToProcess & 0b11000000) == 0b10000000) {
+ // 10xxxxxx, a continuation byte.
+ mUtf8InputBuffer[mUtf8Index++] = byteToProcess;
+ if (--mUtf8ToFollow == 0) {
+ byte firstByteMask = (byte) (mUtf8Index == 2 ? 0b00011111 : (mUtf8Index == 3 ? 0b00001111 : 0b00000111));
+ int codePoint = (mUtf8InputBuffer[0] & firstByteMask);
+ for (int i = 1; i < mUtf8Index; i++)
+ codePoint = ((codePoint << 6) | (mUtf8InputBuffer[i] & 0b00111111));
+ if (((codePoint <= 0b1111111) && mUtf8Index > 1) || (codePoint < 0b11111111111 && mUtf8Index > 2)
+ || (codePoint < 0b1111111111111111 && mUtf8Index > 3)) {
+ // Overlong encoding.
+ codePoint = UNICODE_REPLACEMENT_CHAR;
+ }
+
+ mUtf8Index = mUtf8ToFollow = 0;
+
+ if (codePoint >= 0x80 && codePoint <= 0x9F) {
+ // Sequence decoded to a C1 control character which is the same as escape followed by
+ // ((code & 0x7F) + 0x40).
+ processCodePoint(/* escape (hexadecimal=0x1B, octal=033): */27);
+ processCodePoint((codePoint & 0x7F) + 0x40);
+ } else {
+ switch (Character.getType(codePoint)) {
+ case Character.UNASSIGNED:
+ case Character.SURROGATE:
+ codePoint = UNICODE_REPLACEMENT_CHAR;
+ }
+ processCodePoint(codePoint);
+ }
+ }
+ } else {
+ // Not a UTF-8 continuation byte so replace the entire sequence up to now with the replacement char:
+ mUtf8Index = mUtf8ToFollow = 0;
+ emitCodePoint(UNICODE_REPLACEMENT_CHAR);
+ // The Unicode Standard Version 6.2 – Core Specification
+ // (http://www.unicode.org/versions/Unicode6.2.0/ch03.pdf):
+ // "If the converter encounters an ill-formed UTF-8 code unit sequence which starts with a valid first
+ // byte, but which does not continue with valid successor bytes (see Table 3-7), it must not consume the
+ // successor bytes as part of the ill-formed subsequence
+ // whenever those successor bytes themselves constitute part of a well-formed UTF-8 code unit
+ // subsequence."
+ processByte(byteToProcess);
+ }
+ } else {
+ if ((byteToProcess & 0b10000000) == 0) { // The leading bit is not set so it is a 7-bit ASCII character.
+ processCodePoint(byteToProcess);
+ return;
+ } else if ((byteToProcess & 0b11100000) == 0b11000000) { // 110xxxxx, a two-byte sequence.
+ mUtf8ToFollow = 1;
+ } else if ((byteToProcess & 0b11110000) == 0b11100000) { // 1110xxxx, a three-byte sequence.
+ mUtf8ToFollow = 2;
+ } else if ((byteToProcess & 0b11111000) == 0b11110000) { // 11110xxx, a four-byte sequence.
+ mUtf8ToFollow = 3;
+ } else {
+ // Not a valid UTF-8 sequence start, signal invalid data:
+ processCodePoint(UNICODE_REPLACEMENT_CHAR);
+ return;
+ }
+ mUtf8InputBuffer[mUtf8Index++] = byteToProcess;
+ }
+ }
+
+ public void processCodePoint(int b) {
+ switch (b) {
+ case 0: // Null character (NUL, ^@). Do nothing.
+ break;
+ case 7: // Bell (BEL, ^G, \a). If in an OSC sequence, BEL may terminate a string; otherwise signal bell.
+ if (mEscapeState == ESC_OSC)
+ doOsc(b);
+ else
+ mSession.onBell();
+ break;
+ case 8: // Backspace (BS, ^H).
+ if (mLeftMargin == mCursorCol) {
+ // Jump to previous line if it was auto-wrapped.
+ int previousRow = mCursorRow - 1;
+ if (previousRow >= 0 && mScreen.getLineWrap(previousRow)) {
+ mScreen.clearLineWrap(previousRow);
+ setCursorRowCol(previousRow, mRightMargin - 1);
+ }
+ } else {
+ setCursorCol(mCursorCol - 1);
+ }
+ break;
+ case 9: // Horizontal tab_switcher (HT, \t) - move to next tab_switcher stop, but not past edge of screen
+ // XXX: Should perhaps use color if writing to new cells. Try with
+ // printf "\033[41m\tXX\033[0m\n"
+ // The OSX Terminal.app colors the spaces from the tab_switcher red, but xterm does not.
+ // Note that Terminal.app only colors on new cells, in e.g.
+ // printf "\033[41m\t\r\033[42m\tXX\033[0m\n"
+ // the first cells are created with a red background, but when tabbing over
+ // them again with a green background they are not overwritten.
+ mCursorCol = nextTabStop(1);
+ break;
+ case 10: // Line feed (LF, \n).
+ case 11: // Vertical tab_switcher (VT, \v).
+ case 12: // Form feed (FF, \f).
+ doLinefeed();
+ break;
+ case 13: // Carriage return (CR, \r).
+ setCursorCol(mLeftMargin);
+ break;
+ case 14: // Shift Out (Ctrl-N, SO) → Switch to Alternate Character Set. This invokes the G1 character set.
+ mUseLineDrawingUsesG0 = false;
+ break;
+ case 15: // Shift In (Ctrl-O, SI) → Switch to Standard Character Set. This invokes the G0 character set.
+ mUseLineDrawingUsesG0 = true;
+ break;
+ case 24: // CAN.
+ case 26: // SUB.
+ if (mEscapeState != ESC_NONE) {
+ // FIXME: What is this??
+ mEscapeState = ESC_NONE;
+ emitCodePoint(127);
+ }
+ break;
+ case 27: // ESC
+ // Starts an escape sequence unless we're parsing a string
+ if (mEscapeState == ESC_P) {
+ // XXX: Ignore escape when reading device control sequence, since it may be part of string terminator.
+ return;
+ } else if (mEscapeState != ESC_OSC) {
+ startEscapeSequence();
+ } else {
+ doOsc(b);
+ }
+ break;
+ default:
+ mContinueSequence = false;
+ switch (mEscapeState) {
+ case ESC_NONE:
+ if (b >= 32) emitCodePoint(b);
+ break;
+ case ESC:
+ doEsc(b);
+ break;
+ case ESC_POUND:
+ doEscPound(b);
+ break;
+ case ESC_SELECT_LEFT_PAREN: // Designate G0 Character Set (ISO 2022, VT100).
+ mUseLineDrawingG0 = (b == '0');
+ break;
+ case ESC_SELECT_RIGHT_PAREN: // Designate G1 Character Set (ISO 2022, VT100).
+ mUseLineDrawingG1 = (b == '0');
+ break;
+ case ESC_CSI:
+ doCsi(b);
+ break;
+ case ESC_CSI_EXCLAMATION:
+ if (b == 'p') { // Soft terminal reset (DECSTR, http://vt100.net/docs/vt510-rm/DECSTR).
+ reset();
+ } else {
+ unknownSequence(b);
+ }
+ break;
+ case ESC_CSI_QUESTIONMARK:
+ doCsiQuestionMark(b);
+ break;
+ case ESC_CSI_BIGGERTHAN:
+ doCsiBiggerThan(b);
+ break;
+ case ESC_CSI_DOLLAR:
+ boolean originMode = isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE);
+ int effectiveTopMargin = originMode ? mTopMargin : 0;
+ int effectiveBottomMargin = originMode ? mBottomMargin : mRows;
+ int effectiveLeftMargin = originMode ? mLeftMargin : 0;
+ int effectiveRightMargin = originMode ? mRightMargin : mColumns;
+ switch (b) {
+ case 'v': // ${CSI}${SRC_TOP}${SRC_LEFT}${SRC_BOTTOM}${SRC_RIGHT}${SRC_PAGE}${DST_TOP}${DST_LEFT}${DST_PAGE}$v"
+ // Copy rectangular area (DECCRA - http://vt100.net/docs/vt510-rm/DECCRA):
+ // "If Pbs is greater than Pts, or Pls is greater than Prs, the terminal ignores DECCRA.
+ // The coordinates of the rectangular area are affected by the setting of origin mode (DECOM).
+ // DECCRA is not affected by the page margins.
+ // The copied text takes on the line attributes of the destination area.
+ // If the value of Pt, Pl, Pb, or Pr exceeds the width or height of the active page, then the value
+ // is treated as the width or height of that page.
+ // If the destination area is partially off the page, then DECCRA clips the off-page data.
+ // DECCRA does not change the active cursor position."
+ int topSource = Math.min(getArg(0, 1, true) - 1 + effectiveTopMargin, mRows);
+ int leftSource = Math.min(getArg(1, 1, true) - 1 + effectiveLeftMargin, mColumns);
+ // Inclusive, so do not subtract one:
+ int bottomSource = Math.min(Math.max(getArg(2, mRows, true) + effectiveTopMargin, topSource), mRows);
+ int rightSource = Math.min(Math.max(getArg(3, mColumns, true) + effectiveLeftMargin, leftSource), mColumns);
+ // int sourcePage = getArg(4, 1, true);
+ int destionationTop = Math.min(getArg(5, 1, true) - 1 + effectiveTopMargin, mRows);
+ int destinationLeft = Math.min(getArg(6, 1, true) - 1 + effectiveLeftMargin, mColumns);
+ // int destinationPage = getArg(7, 1, true);
+ int heightToCopy = Math.min(mRows - destionationTop, bottomSource - topSource);
+ int widthToCopy = Math.min(mColumns - destinationLeft, rightSource - leftSource);
+ mScreen.blockCopy(leftSource, topSource, widthToCopy, heightToCopy, destinationLeft, destionationTop);
+ break;
+ case '{': // ${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${"
+ // Selective erase rectangular area (DECSERA - http://www.vt100.net/docs/vt510-rm/DECSERA).
+ case 'x': // ${CSI}${CHAR};${TOP}${LEFT}${BOTTOM}${RIGHT}$x"
+ // Fill rectangular area (DECFRA - http://www.vt100.net/docs/vt510-rm/DECFRA).
+ case 'z': // ${CSI}$${TOP}${LEFT}${BOTTOM}${RIGHT}$z"
+ // Erase rectangular area (DECERA - http://www.vt100.net/docs/vt510-rm/DECERA).
+ boolean erase = b != 'x';
+ boolean selective = b == '{';
+ // Only DECSERA keeps visual attributes, DECERA does not:
+ boolean keepVisualAttributes = erase && selective;
+ int argIndex = 0;
+ int fillChar = erase ? ' ' : getArg(argIndex++, -1, true);
+ // "Pch can be any value from 32 to 126 or from 160 to 255. If Pch is not in this range, then the
+ // terminal ignores the DECFRA command":
+ if ((fillChar >= 32 && fillChar <= 126) || (fillChar >= 160 && fillChar <= 255)) {
+ // "If the value of Pt, Pl, Pb, or Pr exceeds the width or height of the active page, the value
+ // is treated as the width or height of that page."
+ int top = Math.min(getArg(argIndex++, 1, true) + effectiveTopMargin, effectiveBottomMargin + 1);
+ int left = Math.min(getArg(argIndex++, 1, true) + effectiveLeftMargin, effectiveRightMargin + 1);
+ int bottom = Math.min(getArg(argIndex++, mRows, true) + effectiveTopMargin, effectiveBottomMargin);
+ int right = Math.min(getArg(argIndex, mColumns, true) + effectiveLeftMargin, effectiveRightMargin);
+ long style = getStyle();
+ for (int row = top - 1; row < bottom; row++)
+ for (int col = left - 1; col < right; col++)
+ if (!selective || (TextStyle.decodeEffect(mScreen.getStyleAt(row, col)) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) == 0)
+ mScreen.setChar(col, row, fillChar, keepVisualAttributes ? mScreen.getStyleAt(row, col) : style);
+ }
+ break;
+ case 'r': // "${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${ATTRIBUTES}$r"
+ // Change attributes in rectangular area (DECCARA - http://vt100.net/docs/vt510-rm/DECCARA).
+ case 't': // "${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${ATTRIBUTES}$t"
+ // Reverse attributes in rectangular area (DECRARA - http://www.vt100.net/docs/vt510-rm/DECRARA).
+ boolean reverse = b == 't';
+ // FIXME: "coordinates of the rectangular area are affected by the setting of origin mode (DECOM)".
+ int top = Math.min(getArg(0, 1, true) - 1, effectiveBottomMargin) + effectiveTopMargin;
+ int left = Math.min(getArg(1, 1, true) - 1, effectiveRightMargin) + effectiveLeftMargin;
+ int bottom = Math.min(getArg(2, mRows, true) + 1, effectiveBottomMargin - 1) + effectiveTopMargin;
+ int right = Math.min(getArg(3, mColumns, true) + 1, effectiveRightMargin - 1) + effectiveLeftMargin;
+ if (mArgIndex >= 4) {
+ if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1;
+ for (int i = 4; i <= mArgIndex; i++) {
+ int bits = 0;
+ boolean setOrClear = true; // True if setting, false if clearing.
+ switch (getArg(i, 0, false)) {
+ case 0: // Attributes off (no bold, no underline, no blink, positive image).
+ bits = (TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE | TextStyle.CHARACTER_ATTRIBUTE_BLINK
+ | TextStyle.CHARACTER_ATTRIBUTE_INVERSE);
+ if (!reverse) setOrClear = false;
+ break;
+ case 1: // Bold.
+ bits = TextStyle.CHARACTER_ATTRIBUTE_BOLD;
+ break;
+ case 4: // Underline.
+ bits = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE;
+ break;
+ case 5: // Blink.
+ bits = TextStyle.CHARACTER_ATTRIBUTE_BLINK;
+ break;
+ case 7: // Negative image.
+ bits = TextStyle.CHARACTER_ATTRIBUTE_INVERSE;
+ break;
+ case 22: // No bold.
+ bits = TextStyle.CHARACTER_ATTRIBUTE_BOLD;
+ setOrClear = false;
+ break;
+ case 24: // No underline.
+ bits = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE;
+ setOrClear = false;
+ break;
+ case 25: // No blink.
+ bits = TextStyle.CHARACTER_ATTRIBUTE_BLINK;
+ setOrClear = false;
+ break;
+ case 27: // Positive image.
+ bits = TextStyle.CHARACTER_ATTRIBUTE_INVERSE;
+ setOrClear = false;
+ break;
+ }
+ if (reverse && !setOrClear) {
+ // Reverse attributes in rectangular area ignores non-(1,4,5,7) bits.
+ } else {
+ mScreen.setOrClearEffect(bits, setOrClear, reverse, isDecsetInternalBitSet(DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE),
+ effectiveLeftMargin, effectiveRightMargin, top, left, bottom, right);
+ }
+ }
+ } else {
+ // Do nothing.
+ }
+ break;
+ default:
+ unknownSequence(b);
+ }
+ break;
+ case ESC_CSI_DOUBLE_QUOTE:
+ if (b == 'q') {
+ // http://www.vt100.net/docs/vt510-rm/DECSCA
+ int arg = getArg0(0);
+ if (arg == 0 || arg == 2) {
+ // DECSED and DECSEL can erase characters.
+ mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_PROTECTED;
+ } else if (arg == 1) {
+ // DECSED and DECSEL cannot erase characters.
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_PROTECTED;
+ } else {
+ unknownSequence(b);
+ }
+ } else {
+ unknownSequence(b);
+ }
+ break;
+ case ESC_CSI_SINGLE_QUOTE:
+ if (b == '}') { // Insert Ps Column(s) (default = 1) (DECIC), VT420 and up.
+ int columnsAfterCursor = mRightMargin - mCursorCol;
+ int columnsToInsert = Math.min(getArg0(1), columnsAfterCursor);
+ int columnsToMove = columnsAfterCursor - columnsToInsert;
+ mScreen.blockCopy(mCursorCol, 0, columnsToMove, mRows, mCursorCol + columnsToInsert, 0);
+ blockClear(mCursorCol, 0, columnsToInsert, mRows);
+ } else if (b == '~') { // Delete Ps Column(s) (default = 1) (DECDC), VT420 and up.
+ int columnsAfterCursor = mRightMargin - mCursorCol;
+ int columnsToDelete = Math.min(getArg0(1), columnsAfterCursor);
+ int columnsToMove = columnsAfterCursor - columnsToDelete;
+ mScreen.blockCopy(mCursorCol + columnsToDelete, 0, columnsToMove, mRows, mCursorCol, 0);
+ blockClear(mCursorRow + columnsToMove, 0, columnsToDelete, mRows);
+ } else {
+ unknownSequence(b);
+ }
+ break;
+ case ESC_PERCENT:
+ break;
+ case ESC_OSC:
+ doOsc(b);
+ break;
+ case ESC_OSC_ESC:
+ doOscEsc(b);
+ break;
+ case ESC_P:
+ doDeviceControl(b);
+ break;
+ case ESC_CSI_QUESTIONMARK_ARG_DOLLAR:
+ if (b == 'p') {
+ // Request DEC private mode (DECRQM).
+ int mode = getArg0(0);
+ int value;
+ if (mode == 47 || mode == 1047 || mode == 1049) {
+ // This state is carried by mScreen pointer.
+ value = (mScreen == mAltBuffer) ? 1 : 2;
+ } else {
+ int internalBit = mapDecSetBitToInternalBit(mode);
+ if (internalBit == -1) {
+ value = isDecsetInternalBitSet(internalBit) ? 1 : 2; // 1=set, 2=reset.
+ } else {
+ Log.e(EmulatorDebug.LOG_TAG, "Got DECRQM for unrecognized private DEC mode=" + mode);
+ value = 0; // 0=not recognized, 3=permanently set, 4=permanently reset
+ }
+ }
+ mSession.write(String.format(Locale.US, "\033[?%d;%d$y", mode, value));
+ } else {
+ unknownSequence(b);
+ }
+ break;
+ case ESC_CSI_ARGS_SPACE:
+ int arg = getArg0(0);
+ switch (b) {
+ case 'q': // "${CSI}${STYLE} q" - set cursor style (http://www.vt100.net/docs/vt510-rm/DECSCUSR).
+ switch (arg) {
+ case 0: // Blinking block.
+ case 1: // Blinking block.
+ case 2: // Steady block.
+ mCursorStyle = CURSOR_STYLE_BLOCK;
+ break;
+ case 3: // Blinking underline.
+ case 4: // Steady underline.
+ mCursorStyle = CURSOR_STYLE_UNDERLINE;
+ break;
+ case 5: // Blinking bar (xterm addition).
+ case 6: // Steady bar (xterm addition).
+ mCursorStyle = CURSOR_STYLE_BAR;
+ break;
+ }
+ break;
+ case 't':
+ case 'u':
+ // Set margin-bell volume - ignore.
+ break;
+ default:
+ unknownSequence(b);
+ }
+ break;
+ case ESC_CSI_ARGS_ASTERIX:
+ int attributeChangeExtent = getArg0(0);
+ if (b == 'x' && (attributeChangeExtent >= 0 && attributeChangeExtent <= 2)) {
+ // Select attribute change extent (DECSACE - http://www.vt100.net/docs/vt510-rm/DECSACE).
+ setDecsetinternalBit(DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE, attributeChangeExtent == 2);
+ } else {
+ unknownSequence(b);
+ }
+ break;
+ default:
+ unknownSequence(b);
+ break;
+ }
+ if (!mContinueSequence) mEscapeState = ESC_NONE;
+ break;
+ }
+ }
+
+ /** When in {@link #ESC_P} ("device control") sequence. */
+ private void doDeviceControl(int b) {
+ switch (b) {
+ case (byte) '\\': // End of ESC \ string Terminator
+ {
+ String dcs = mOSCOrDeviceControlArgs.toString();
+ // DCS $ q P t ST. Request Status String (DECRQSS)
+ if (dcs.startsWith("$q")) {
+ if (dcs.equals("$q\"p")) {
+ // DECSCL, conformance level, http://www.vt100.net/docs/vt510-rm/DECSCL:
+ String csiString = "64;1\"p";
+ mSession.write("\033P1$r" + csiString + "\033\\");
+ } else {
+ finishSequenceAndLogError("Unrecognized DECRQSS string: '" + dcs + "'");
+ }
+ } else if (dcs.startsWith("+q")) {
+ // Request Termcap/Terminfo String. The string following the "q" is a list of names encoded in
+ // hexadecimal (2 digits per character) separated by ; which correspond to termcap or terminfo key
+ // names.
+ // Two special features are also recognized, which are not key names: Co for termcap colors (or colors
+ // for terminfo colors), and TN for termcap name (or name for terminfo name).
+ // xterm responds with DCS 1 + r P t ST for valid requests, adding to P t an = , and the value of the
+ // corresponding string that xterm would send, or DCS 0 + r P t ST for invalid requests. The strings are
+ // encoded in hexadecimal (2 digits per character).
+ // Example:
+ // :kr=\EOC: ks=\E[?1h\E=: ku=\EOA: le=^H:mb=\E[5m:md=\E[1m:\
+ // where
+ // kd=down-arrow key
+ // kl=left-arrow key
+ // kr=right-arrow key
+ // ku=up-arrow key
+ // #2=key_shome, "shifted home"
+ // #4=key_sleft, "shift arrow left"
+ // %i=key_sright, "shift arrow right"
+ // *7=key_send, "shifted end"
+ // k1=F1 function key
+
+ // Example: Request for ku is "ESC P + q 6 b 7 5 ESC \", where 6b7d=ku in hexadecimal.
+ // Xterm response in normal cursor mode:
+ // "<27> P 1 + r 6 b 7 5 = 1 B 5 B 4 1" where 0x1B 0x5B 0x41 = 27 91 65 = ESC [ A
+ // Xterm response in application cursor mode:
+ // "<27> P 1 + r 6 b 7 5 = 1 B 5 B 4 1" where 0x1B 0x4F 0x41 = 27 91 65 = ESC 0 A
+
+ // #4 is "shift arrow left":
+ // *** Device Control (DCS) for '#4'- 'ESC P + q 23 34 ESC \'
+ // Response: <27> P 1 + r 2 3 3 4 = 1 B 5 B 3 1 3 B 3 2 4 4 <27> \
+ // where 0x1B 0x5B 0x31 0x3B 0x32 0x44 = ESC [ 1 ; 2 D
+ // which we find in: TermKeyListener.java: KEY_MAP.put(KEYMOD_SHIFT | KEYCODE_DPAD_LEFT, "\033[1;2D");
+
+ // See http://h30097.www3.hp.com/docs/base_doc/DOCUMENTATION/V40G_HTML/MAN/MAN4/0178____.HTM for what to
+ // respond, as well as http://www.freebsd.org/cgi/man.cgi?query=termcap&sektion=5#CAPABILITIES for
+ // the meaning of e.g. "ku", "kd", "kr", "kl"
+
+ for (String part : dcs.substring(2).split(";")) {
+ if (part.length() % 2 == 0) {
+ StringBuilder transBuffer = new StringBuilder();
+ for (int i = 0; i < part.length(); i += 2) {
+ char c = (char) Long.decode("0x" + part.charAt(i) + "" + part.charAt(i + 1)).longValue();
+ transBuffer.append(c);
+ }
+ String trans = transBuffer.toString();
+ String responseValue;
+ switch (trans) {
+ case "Co":
+ case "colors":
+ responseValue = "256"; // Number of colors.
+ break;
+ case "TN":
+ case "name":
+ responseValue = "xterm";
+ break;
+ default:
+ responseValue = KeyHandler.getCodeFromTermcap(trans, isDecsetInternalBitSet(DECSET_BIT_APPLICATION_CURSOR_KEYS),
+ isDecsetInternalBitSet(DECSET_BIT_APPLICATION_KEYPAD));
+ break;
+ }
+ if (responseValue == null) {
+ switch (trans) {
+ case "%1": // Help key - ignore
+ case "&8": // Undo key - ignore.
+ break;
+ default:
+ Log.w(EmulatorDebug.LOG_TAG, "Unhandled termcap/terminfo name: '" + trans + "'");
+ }
+ // Respond with invalid request:
+ mSession.write("\033P0+r" + part + "\033\\");
+ } else {
+ StringBuilder hexEncoded = new StringBuilder();
+ for (int j = 0; j < responseValue.length(); j++) {
+ hexEncoded.append(String.format("%02X", (int) responseValue.charAt(j)));
+ }
+ mSession.write("\033P1+r" + part + "=" + hexEncoded + "\033\\");
+ }
+ } else {
+ Log.e(EmulatorDebug.LOG_TAG, "Invalid device termcap/terminfo name of odd length: " + part);
+ }
+ }
+ } else {
+ if (LOG_ESCAPE_SEQUENCES)
+ Log.e(EmulatorDebug.LOG_TAG, "Unrecognized device control string: " + dcs);
+ }
+ finishSequence();
+ }
+ break;
+ default:
+ if (mOSCOrDeviceControlArgs.length() > MAX_OSC_STRING_LENGTH) {
+ // Too long.
+ mOSCOrDeviceControlArgs.setLength(0);
+ finishSequence();
+ } else {
+ mOSCOrDeviceControlArgs.appendCodePoint(b);
+ continueSequence(mEscapeState);
+ }
+ }
+ }
+
+ private int nextTabStop(int numTabs) {
+ for (int i = mCursorCol + 1; i < mColumns; i++)
+ if (mTabStop[i] && --numTabs == 0) return Math.min(i, mRightMargin);
+ return mRightMargin - 1;
+ }
+
+ /** Process byte while in the {@link #ESC_CSI_QUESTIONMARK} escape state. */
+ private void doCsiQuestionMark(int b) {
+ switch (b) {
+ case 'J': // Selective erase in display (DECSED) - http://www.vt100.net/docs/vt510-rm/DECSED.
+ case 'K': // Selective erase in line (DECSEL) - http://vt100.net/docs/vt510-rm/DECSEL.
+ mAboutToAutoWrap = false;
+ int fillChar = ' ';
+ int startCol = -1;
+ int startRow = -1;
+ int endCol = -1;
+ int endRow = -1;
+ boolean justRow = (b == 'K');
+ switch (getArg0(0)) {
+ case 0: // Erase from the active position to the end, inclusive (default).
+ startCol = mCursorCol;
+ startRow = mCursorRow;
+ endCol = mColumns;
+ endRow = justRow ? (mCursorRow + 1) : mRows;
+ break;
+ case 1: // Erase from start to the active position, inclusive.
+ startCol = 0;
+ startRow = justRow ? mCursorRow : 0;
+ endCol = mCursorCol + 1;
+ endRow = mCursorRow + 1;
+ break;
+ case 2: // Erase all of the display/line.
+ startCol = 0;
+ startRow = justRow ? mCursorRow : 0;
+ endCol = mColumns;
+ endRow = justRow ? (mCursorRow + 1) : mRows;
+ break;
+ default:
+ unknownSequence(b);
+ break;
+ }
+ long style = getStyle();
+ for (int row = startRow; row < endRow; row++) {
+ for (int col = startCol; col < endCol; col++) {
+ if ((TextStyle.decodeEffect(mScreen.getStyleAt(row, col)) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) == 0)
+ mScreen.setChar(col, row, fillChar, style);
+ }
+ }
+ break;
+ case 'h':
+ case 'l':
+ if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1;
+ for (int i = 0; i <= mArgIndex; i++)
+ doDecSetOrReset(b == 'h', mArgs[i]);
+ break;
+ case 'n': // Device Status Report (DSR, DEC-specific).
+ switch (getArg0(-1)) {
+ case 6:
+ // Extended Cursor Position (DECXCPR - http://www.vt100.net/docs/vt510-rm/DECXCPR). Page=1.
+ mSession.write(String.format(Locale.US, "\033[?%d;%d;1R", mCursorRow + 1, mCursorCol + 1));
+ break;
+ default:
+ finishSequence();
+ return;
+ }
+ break;
+ case 'r':
+ case 's':
+ if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1;
+ for (int i = 0; i <= mArgIndex; i++) {
+ int externalBit = mArgs[i];
+ int internalBit = mapDecSetBitToInternalBit(externalBit);
+ if (internalBit == -1) {
+ Log.w(EmulatorDebug.LOG_TAG, "Ignoring request to save/recall decset bit=" + externalBit);
+ } else {
+ if (b == 's') {
+ mSavedDecSetFlags |= internalBit;
+ } else {
+ doDecSetOrReset((mSavedDecSetFlags & internalBit) != 0, externalBit);
+ }
+ }
+ }
+ break;
+ case '$':
+ continueSequence(ESC_CSI_QUESTIONMARK_ARG_DOLLAR);
+ return;
+ default:
+ parseArg(b);
+ }
+ }
+
+ public void doDecSetOrReset(boolean setting, int externalBit) {
+ int internalBit = mapDecSetBitToInternalBit(externalBit);
+ if (internalBit != -1) {
+ setDecsetinternalBit(internalBit, setting);
+ }
+ switch (externalBit) {
+ case 1: // Application Cursor Keys (DECCKM).
+ break;
+ case 3: // Set: 132 column mode (. Reset: 80 column mode. ANSI name: DECCOLM.
+ // We don't actually set/reset 132 cols, but we do want the side effects
+ // (FIXME: Should only do this if the 95 DECSET bit (DECNCSM) is set, and if changing value?):
+ // Sets the left, right, top and bottom scrolling margins to their default positions, which is important for
+ // the "reset" utility to really reset the terminal:
+ mLeftMargin = mTopMargin = 0;
+ mBottomMargin = mRows;
+ mRightMargin = mColumns;
+ // "DECCOLM resets vertical split screen mode (DECLRMM) to unavailable":
+ setDecsetinternalBit(DECSET_BIT_LEFTRIGHT_MARGIN_MODE, false);
+ // "Erases all data in page memory":
+ blockClear(0, 0, mColumns, mRows);
+ setCursorRowCol(0, 0);
+ break;
+ case 4: // DECSCLM-Scrolling Mode. Ignore.
+ break;
+ case 5: // Reverse video. No action.
+ break;
+ case 6: // Set: Origin Mode. Reset: Normal Cursor Mode. Ansi name: DECOM.
+ if (setting) setCursorPosition(0, 0);
+ break;
+ case 7: // Wrap-around bit, not specific action.
+ case 8: // Auto-repeat Keys (DECARM). Do not implement.
+ case 9: // X10 mouse reporting - outdated. Do not implement.
+ case 12: // Control cursor blinking - ignore.
+ case 25: // Hide/show cursor - no action needed, renderer will check with isShowingCursor().
+ case 40: // Allow 80 => 132 Mode, ignore.
+ case 45: // TODO: Reverse wrap-around. Implement???
+ case 66: // Application keypad (DECNKM).
+ break;
+ case 69: // Left and right margin mode (DECLRMM).
+ if (!setting) {
+ mLeftMargin = 0;
+ mRightMargin = mColumns;
+ }
+ break;
+ case 1000:
+ case 1001:
+ case 1002:
+ case 1003:
+ case 1004:
+ case 1005: // UTF-8 mouse mode, ignore.
+ case 1006: // SGR Mouse Mode
+ case 1015:
+ case 1034: // Interpret "meta" key, sets eighth bit.
+ break;
+ case 1048: // Set: Save cursor as in DECSC. Reset: Restore cursor as in DECRC.
+ if (setting)
+ saveCursor();
+ else
+ restoreCursor();
+ break;
+ case 47:
+ case 1047:
+ case 1049: {
+ // Set: Save cursor as in DECSC and use Alternate Screen Buffer, clearing it first.
+ // Reset: Use Normal Screen Buffer and restore cursor as in DECRC.
+ TerminalBuffer newScreen = setting ? mAltBuffer : mMainBuffer;
+ if (newScreen != mScreen) {
+ boolean resized = !(newScreen.mColumns == mColumns && newScreen.mScreenRows == mRows);
+ if (setting) saveCursor();
+ mScreen = newScreen;
+ if (!setting) {
+ int col = mSavedStateMain.mSavedCursorCol;
+ int row = mSavedStateMain.mSavedCursorRow;
+ restoreCursor();
+ if (resized) {
+ // Restore cursor position _not_ clipped to current screen (let resizeScreen() handle that):
+ mCursorCol = col;
+ mCursorRow = row;
+ }
+ }
+ // Check if buffer size needs to be updated:
+ if (resized) resizeScreen();
+ // Clear new screen if alt buffer:
+ if (newScreen == mAltBuffer)
+ newScreen.blockSet(0, 0, mColumns, mRows, ' ', getStyle());
+ }
+ break;
+ }
+ case 2004:
+ // Bracketed paste mode - setting bit is enough.
+ break;
+ default:
+ unknownParameter(externalBit);
+ break;
+ }
+ }
+
+ private void doCsiBiggerThan(int b) {
+ switch (b) {
+ case 'c': // "${CSI}>c" or "${CSI}>c". Secondary Device Attributes (DA2).
+ // Originally this was used for the terminal to respond with "identification code, firmware version level,
+ // and hardware options" (http://vt100.net/docs/vt510-rm/DA2), with the first "41" meaning the VT420
+ // terminal type. This is not used anymore, but the second version level field has been changed by xterm
+ // to mean it's release number ("patch numbers" listed at http://invisible-island.net/xterm/xterm.log.html),
+ // and some applications use it as a feature check:
+ // * tmux used to have a "xterm won't reach version 500 for a while so set that as the upper limit" check,
+ // and then check "xterm_version > 270" if rectangular area operations such as DECCRA could be used.
+ // * vim checks xterm version number >140 for "Request termcap/terminfo string" functionality >276 for SGR
+ // mouse report.
+ // The third number is a keyboard identifier not used nowadays.
+ mSession.write("\033[>41;320;0c");
+ break;
+ case 'm':
+ // https://bugs.launchpad.net/gnome-terminal/+bug/96676/comments/25
+ // Depending on the first number parameter, this can set one of the xterm resources
+ // modifyKeyboard, modifyCursorKeys, modifyFunctionKeys and modifyOtherKeys.
+ // http://invisible-island.net/xterm/manpage/xterm.html#RESOURCES
+
+ // * modifyKeyboard (parameter=1):
+ // Normally xterm makes a special case regarding modifiers (shift, control, etc.) to handle special keyboard
+ // layouts (legacy and vt220). This is done to provide compatible keyboards for DEC VT220 and related
+ // terminals that implement user-defined keys (UDK).
+ // The bits of the resource value selectively enable modification of the given category when these keyboards
+ // are selected. The default is "0":
+ // (0) The legacy/vt220 keyboards interpret only the Control-modifier when constructing numbered
+ // function-keys. Other special keys are not modified.
+ // (1) allows modification of the numeric keypad
+ // (2) allows modification of the editing keypad
+ // (4) allows modification of function-keys, overrides use of Shift-modifier for UDK.
+ // (8) allows modification of other special keys
+
+ // * modifyCursorKeys (parameter=2):
+ // Tells how to handle the special case where Control-, Shift-, Alt- or Meta-modifiers are used to add a
+ // parameter to the escape sequence returned by a cursor-key. The default is "2".
+ // - Set it to -1 to disable it.
+ // - Set it to 0 to use the old/obsolete behavior.
+ // - Set it to 1 to prefix modified sequences with CSI.
+ // - Set it to 2 to force the modifier to be the second parameter if it would otherwise be the first.
+ // - Set it to 3 to mark the sequence with a ">" to hint that it is private.
+
+ // * modifyFunctionKeys (parameter=3):
+ // Tells how to handle the special case where Control-, Shift-, Alt- or Meta-modifiers are used to add a
+ // parameter to the escape sequence returned by a (numbered) function-
+ // key. The default is "2". The resource values are similar to modifyCursorKeys:
+ // Set it to -1 to permit the user to use shift- and control-modifiers to construct function-key strings
+ // using the normal encoding scheme.
+ // - Set it to 0 to use the old/obsolete behavior.
+ // - Set it to 1 to prefix modified sequences with CSI.
+ // - Set it to 2 to force the modifier to be the second parameter if it would otherwise be the first.
+ // - Set it to 3 to mark the sequence with a ">" to hint that it is private.
+ // If modifyFunctionKeys is zero, xterm uses Control- and Shift-modifiers to allow the user to construct
+ // numbered function-keys beyond the set provided by the keyboard:
+ // (Control) adds the value given by the ctrlFKeys resource.
+ // (Shift) adds twice the value given by the ctrlFKeys resource.
+ // (Control/Shift) adds three times the value given by the ctrlFKeys resource.
+ //
+ // As a special case, legacy (when oldFunctionKeys is true) or vt220 (when sunKeyboard is true)
+ // keyboards interpret only the Control-modifier when constructing numbered function-keys.
+ // This is done to provide compatible keyboards for DEC VT220 and related terminals that
+ // implement user-defined keys (UDK).
+
+ // * modifyOtherKeys (parameter=4):
+ // Like modifyCursorKeys, tells xterm to construct an escape sequence for other keys (such as "2") when
+ // modified by Control-, Alt- or Meta-modifiers. This feature does not apply to function keys and
+ // well-defined keys such as ESC or the control keys. The default is "0".
+ // (0) disables this feature.
+ // (1) enables this feature for keys except for those with well-known behavior, e.g., Tab, Backarrow and
+ // some special control character cases, e.g., Control-Space to make a NUL.
+ // (2) enables this feature for keys including the exceptions listed.
+ Log.e(EmulatorDebug.LOG_TAG, "(ignored) CSI > MODIFY RESOURCE: " + getArg0(-1) + " to " + getArg1(-1));
+ break;
+ default:
+ parseArg(b);
+ break;
+ }
+ }
+
+ private void startEscapeSequence() {
+ mEscapeState = ESC;
+ mArgIndex = 0;
+ Arrays.fill(mArgs, -1);
+ }
+
+ private void doLinefeed() {
+ int newCursorRow = mCursorRow + 1;
+ if (newCursorRow >= mBottomMargin) {
+ scrollDownOneLine();
+ newCursorRow = mBottomMargin - 1;
+ }
+ setCursorRow(newCursorRow);
+ }
+
+ private void continueSequence(int state) {
+ mEscapeState = state;
+ mContinueSequence = true;
+ }
+
+ private void doEscPound(int b) {
+ switch (b) {
+ case '8': // Esc # 8 - DEC screen alignment test - fill screen with E's.
+ mScreen.blockSet(0, 0, mColumns, mRows, 'E', getStyle());
+ break;
+ default:
+ unknownSequence(b);
+ break;
+ }
+ }
+
+ /** Encountering a character in the {@link #ESC} state. */
+ private void doEsc(int b) {
+ switch (b) {
+ case '#':
+ continueSequence(ESC_POUND);
+ break;
+ case '(':
+ continueSequence(ESC_SELECT_LEFT_PAREN);
+ break;
+ case ')':
+ continueSequence(ESC_SELECT_RIGHT_PAREN);
+ break;
+ case '6': // Back index (http://www.vt100.net/docs/vt510-rm/DECBI). Move left, insert blank column if start.
+ if (mCursorCol > mLeftMargin) {
+ mCursorCol--;
+ } else {
+ int rows = mBottomMargin - mTopMargin;
+ mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin - 1, rows, mLeftMargin + 1, mTopMargin);
+ mScreen.blockSet(mLeftMargin, mTopMargin, 1, rows, ' ', TextStyle.encode(mForeColor, mBackColor, 0));
+ }
+ break;
+ case '7': // DECSC save cursor - http://www.vt100.net/docs/vt510-rm/DECSC
+ saveCursor();
+ break;
+ case '8': // DECRC restore cursor - http://www.vt100.net/docs/vt510-rm/DECRC
+ restoreCursor();
+ break;
+ case '9': // Forward Index (http://www.vt100.net/docs/vt510-rm/DECFI). Move right, insert blank column if end.
+ if (mCursorCol < mRightMargin - 1) {
+ mCursorCol++;
+ } else {
+ int rows = mBottomMargin - mTopMargin;
+ mScreen.blockCopy(mLeftMargin + 1, mTopMargin, mRightMargin - mLeftMargin - 1, rows, mLeftMargin, mTopMargin);
+ mScreen.blockSet(mRightMargin - 1, mTopMargin, 1, rows, ' ', TextStyle.encode(mForeColor, mBackColor, 0));
+ }
+ break;
+ case 'c': // RIS - Reset to Initial State (http://vt100.net/docs/vt510-rm/RIS).
+ reset();
+ blockClear(0, 0, mColumns, mRows);
+ setCursorPosition(0, 0);
+ break;
+ case 'D': // INDEX
+ doLinefeed();
+ break;
+ case 'E': // Next line (http://www.vt100.net/docs/vt510-rm/NEL).
+ setCursorCol(isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE) ? mLeftMargin : 0);
+ doLinefeed();
+ break;
+ case 'F': // Cursor to lower-left corner of screen
+ setCursorRowCol(0, mBottomMargin - 1);
+ break;
+ case 'H': // Tab set
+ mTabStop[mCursorCol] = true;
+ break;
+ case 'M': // "${ESC}M" - reverse index (RI).
+ // http://www.vt100.net/docs/vt100-ug/chapter3.html: "Move the active position to the same horizontal
+ // position on the preceding line. If the active position is at the top margin, a scroll down is performed".
+ if (mCursorRow <= mTopMargin) {
+ mScreen.blockCopy(0, mTopMargin, mColumns, mBottomMargin - (mTopMargin + 1), 0, mTopMargin + 1);
+ blockClear(0, mTopMargin, mColumns);
+ } else {
+ mCursorRow--;
+ }
+ break;
+ case 'N': // SS2, ignore.
+ case '0': // SS3, ignore.
+ break;
+ case 'P': // Device control string
+ mOSCOrDeviceControlArgs.setLength(0);
+ continueSequence(ESC_P);
+ break;
+ case '[':
+ continueSequence(ESC_CSI);
+ break;
+ case '=': // DECKPAM
+ setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, true);
+ break;
+ case ']': // OSC
+ mOSCOrDeviceControlArgs.setLength(0);
+ continueSequence(ESC_OSC);
+ break;
+ case '>': // DECKPNM
+ setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, false);
+ break;
+ default:
+ unknownSequence(b);
+ break;
+ }
+ }
+
+ /** DECSC save cursor - http://www.vt100.net/docs/vt510-rm/DECSC . See {@link #restoreCursor()}. */
+ private void saveCursor() {
+ SavedScreenState state = (mScreen == mMainBuffer) ? mSavedStateMain : mSavedStateAlt;
+ state.mSavedCursorRow = mCursorRow;
+ state.mSavedCursorCol = mCursorCol;
+ state.mSavedEffect = mEffect;
+ state.mSavedForeColor = mForeColor;
+ state.mSavedBackColor = mBackColor;
+ state.mSavedDecFlags = mCurrentDecSetFlags;
+ state.mUseLineDrawingG0 = mUseLineDrawingG0;
+ state.mUseLineDrawingG1 = mUseLineDrawingG1;
+ state.mUseLineDrawingUsesG0 = mUseLineDrawingUsesG0;
+ }
+
+ /** DECRS restore cursor - http://www.vt100.net/docs/vt510-rm/DECRC. See {@link #saveCursor()}. */
+ private void restoreCursor() {
+ SavedScreenState state = (mScreen == mMainBuffer) ? mSavedStateMain : mSavedStateAlt;
+ setCursorRowCol(state.mSavedCursorRow, state.mSavedCursorCol);
+ mEffect = state.mSavedEffect;
+ mForeColor = state.mSavedForeColor;
+ mBackColor = state.mSavedBackColor;
+ int mask = (DECSET_BIT_AUTOWRAP | DECSET_BIT_ORIGIN_MODE);
+ mCurrentDecSetFlags = (mCurrentDecSetFlags & ~mask) | (state.mSavedDecFlags & mask);
+ mUseLineDrawingG0 = state.mUseLineDrawingG0;
+ mUseLineDrawingG1 = state.mUseLineDrawingG1;
+ mUseLineDrawingUsesG0 = state.mUseLineDrawingUsesG0;
+ }
+
+ /** Following a CSI - Control Sequence Introducer, "\033[". {@link #ESC_CSI}. */
+ private void doCsi(int b) {
+ switch (b) {
+ case '!':
+ continueSequence(ESC_CSI_EXCLAMATION);
+ break;
+ case '"':
+ continueSequence(ESC_CSI_DOUBLE_QUOTE);
+ break;
+ case '\'':
+ continueSequence(ESC_CSI_SINGLE_QUOTE);
+ break;
+ case '$':
+ continueSequence(ESC_CSI_DOLLAR);
+ break;
+ case '*':
+ continueSequence(ESC_CSI_ARGS_ASTERIX);
+ break;
+ case '@': {
+ // "CSI{n}@" - Insert ${n} space characters (ICH) - http://www.vt100.net/docs/vt510-rm/ICH.
+ mAboutToAutoWrap = false;
+ int columnsAfterCursor = mColumns - mCursorCol;
+ int spacesToInsert = Math.min(getArg0(1), columnsAfterCursor);
+ int charsToMove = columnsAfterCursor - spacesToInsert;
+ mScreen.blockCopy(mCursorCol, mCursorRow, charsToMove, 1, mCursorCol + spacesToInsert, mCursorRow);
+ blockClear(mCursorCol, mCursorRow, spacesToInsert);
+ }
+ break;
+ case 'A': // "CSI${n}A" - Cursor up (CUU) ${n} rows.
+ setCursorRow(Math.max(mTopMargin, mCursorRow - getArg0(1)));
+ break;
+ case 'B': // "CSI${n}B" - Cursor down (CUD) ${n} rows.
+ setCursorRow(Math.min(mBottomMargin - 1, mCursorRow + getArg0(1)));
+ break;
+ case 'C': // "CSI${n}C" - Cursor forward (CUF).
+ case 'a': // "CSI${n}a" - Horizontal position relative (HPR). From ISO-6428/ECMA-48.
+ setCursorCol(Math.min(mRightMargin - 1, mCursorCol + getArg0(1)));
+ break;
+ case 'D': // "CSI${n}D" - Cursor backward (CUB) ${n} columns.
+ setCursorCol(Math.max(mLeftMargin, mCursorCol - getArg0(1)));
+ break;
+ case 'E': // "CSI{n}E - Cursor Next Line (CNL). From ISO-6428/ECMA-48.
+ setCursorPosition(0, mCursorRow + getArg0(1));
+ break;
+ case 'F': // "CSI{n}F - Cursor Previous Line (CPL). From ISO-6428/ECMA-48.
+ setCursorPosition(0, mCursorRow - getArg0(1));
+ break;
+ case 'G': // "CSI${n}G" - Cursor horizontal absolute (CHA) to column ${n}.
+ setCursorCol(Math.min(Math.max(1, getArg0(1)), mColumns) - 1);
+ break;
+ case 'H': // "${CSI}${ROW};${COLUMN}H" - Cursor position (CUP).
+ case 'f': // "${CSI}${ROW};${COLUMN}f" - Horizontal and Vertical Position (HVP).
+ setCursorPosition(getArg1(1) - 1, getArg0(1) - 1);
+ break;
+ case 'I': // Cursor Horizontal Forward Tabulation (CHT). Move the active position n tabs forward.
+ setCursorCol(nextTabStop(getArg0(1)));
+ break;
+ case 'J': // "${CSI}${0,1,2}J" - Erase in Display (ED)
+ // ED ignores the scrolling margins.
+ switch (getArg0(0)) {
+ case 0: // Erase from the active position to the end of the screen, inclusive (default).
+ blockClear(mCursorCol, mCursorRow, mColumns - mCursorCol);
+ blockClear(0, mCursorRow + 1, mColumns, mRows - (mCursorRow + 1));
+ break;
+ case 1: // Erase from start of the screen to the active position, inclusive.
+ blockClear(0, 0, mColumns, mCursorRow);
+ blockClear(0, mCursorRow, mCursorCol + 1);
+ break;
+ case 2: // Erase all of the display - all lines are erased, changed to single-width, and the cursor does not
+ // move..
+ blockClear(0, 0, mColumns, mRows);
+ break;
+ default:
+ unknownSequence(b);
+ return;
+ }
+ mAboutToAutoWrap = false;
+ break;
+ case 'K': // "CSI{n}K" - Erase in line (EL).
+ switch (getArg0(0)) {
+ case 0: // Erase from the cursor to the end of the line, inclusive (default)
+ blockClear(mCursorCol, mCursorRow, mColumns - mCursorCol);
+ break;
+ case 1: // Erase from the start of the screen to the cursor, inclusive.
+ blockClear(0, mCursorRow, mCursorCol + 1);
+ break;
+ case 2: // Erase all of the line.
+ blockClear(0, mCursorRow, mColumns);
+ break;
+ default:
+ unknownSequence(b);
+ return;
+ }
+ mAboutToAutoWrap = false;
+ break;
+ case 'L': // "${CSI}{N}L" - insert ${N} lines (IL).
+ {
+ int linesAfterCursor = mBottomMargin - mCursorRow;
+ int linesToInsert = Math.min(getArg0(1), linesAfterCursor);
+ int linesToMove = linesAfterCursor - linesToInsert;
+ mScreen.blockCopy(0, mCursorRow, mColumns, linesToMove, 0, mCursorRow + linesToInsert);
+ blockClear(0, mCursorRow, mColumns, linesToInsert);
+ }
+ break;
+ case 'M': // "${CSI}${N}M" - delete N lines (DL).
+ {
+ mAboutToAutoWrap = false;
+ int linesAfterCursor = mBottomMargin - mCursorRow;
+ int linesToDelete = Math.min(getArg0(1), linesAfterCursor);
+ int linesToMove = linesAfterCursor - linesToDelete;
+ mScreen.blockCopy(0, mCursorRow + linesToDelete, mColumns, linesToMove, 0, mCursorRow);
+ blockClear(0, mCursorRow + linesToMove, mColumns, linesToDelete);
+ }
+ break;
+ case 'P': // "${CSI}{N}P" - delete ${N} characters (DCH).
+ {
+ // http://www.vt100.net/docs/vt510-rm/DCH: "If ${N} is greater than the number of characters between the
+ // cursor and the right margin, then DCH only deletes the remaining characters.
+ // As characters are deleted, the remaining characters between the cursor and right margin move to the left.
+ // Character attributes move with the characters. The terminal adds blank spaces with no visual character
+ // attributes at the right margin. DCH has no effect outside the scrolling margins."
+ mAboutToAutoWrap = false;
+ int cellsAfterCursor = mColumns - mCursorCol;
+ int cellsToDelete = Math.min(getArg0(1), cellsAfterCursor);
+ int cellsToMove = cellsAfterCursor - cellsToDelete;
+ mScreen.blockCopy(mCursorCol + cellsToDelete, mCursorRow, cellsToMove, 1, mCursorCol, mCursorRow);
+ blockClear(mCursorCol + cellsToMove, mCursorRow, cellsToDelete);
+ }
+ break;
+ case 'S': { // "${CSI}${N}S" - scroll up ${N} lines (default = 1) (SU).
+ final int linesToScroll = getArg0(1);
+ for (int i = 0; i < linesToScroll; i++)
+ scrollDownOneLine();
+ break;
+ }
+ case 'T':
+ if (mArgIndex == 0) {
+ // "${CSI}${N}T" - Scroll down N lines (default = 1) (SD).
+ // http://vt100.net/docs/vt510-rm/SD: "N is the number of lines to move the user window up in page
+ // memory. N new lines appear at the top of the display. N old lines disappear at the bottom of the
+ // display. You cannot pan past the top margin of the current page".
+ final int linesToScrollArg = getArg0(1);
+ final int linesBetweenTopAndBottomMargins = mBottomMargin - mTopMargin;
+ final int linesToScroll = Math.min(linesBetweenTopAndBottomMargins, linesToScrollArg);
+ mScreen.blockCopy(0, mTopMargin, mColumns, linesBetweenTopAndBottomMargins - linesToScroll, 0, mTopMargin + linesToScroll);
+ blockClear(0, mTopMargin, mColumns, linesToScroll);
+ } else {
+ // "${CSI}${func};${startx};${starty};${firstrow};${lastrow}T" - initiate highlight mouse tracking.
+ unimplementedSequence(b);
+ }
+ break;
+ case 'X': // "${CSI}${N}X" - Erase ${N:=1} character(s) (ECH). FIXME: Clears character attributes?
+ mAboutToAutoWrap = false;
+ mScreen.blockSet(mCursorCol, mCursorRow, Math.min(getArg0(1), mColumns - mCursorCol), 1, ' ', getStyle());
+ break;
+ case 'Z': // Cursor Backward Tabulation (CBT). Move the active position n tabs backward.
+ int numberOfTabs = getArg0(1);
+ int newCol = mLeftMargin;
+ for (int i = mCursorCol - 1; i >= 0; i--)
+ if (mTabStop[i]) {
+ if (--numberOfTabs == 0) {
+ newCol = Math.max(i, mLeftMargin);
+ break;
+ }
+ }
+ mCursorCol = newCol;
+ break;
+ case '?': // Esc [ ? -- start of a private mode set
+ continueSequence(ESC_CSI_QUESTIONMARK);
+ break;
+ case '>': // "Esc [ >" --
+ continueSequence(ESC_CSI_BIGGERTHAN);
+ break;
+ case '`': // Horizontal position absolute (HPA - http://www.vt100.net/docs/vt510-rm/HPA).
+ setCursorColRespectingOriginMode(getArg0(1) - 1);
+ break;
+ case 'c': // Primary Device Attributes (http://www.vt100.net/docs/vt510-rm/DA1) if argument is missing or zero.
+ // The important part that may still be used by some (tmux stores this value but does not currently use it)
+ // is the first response parameter identifying the terminal service class, where we send 64 for "vt420".
+ // This is followed by a list of attributes which is probably unused by applications. Send like xterm.
+ if (getArg0(0) == 0) mSession.write("\033[?64;1;2;6;9;15;18;21;22c");
+ break;
+ case 'd': // ESC [ Pn d - Vert Position Absolute
+ setCursorRow(Math.min(Math.max(1, getArg0(1)), mRows) - 1);
+ break;
+ case 'e': // Vertical Position Relative (VPR). From ISO-6429 (ECMA-48).
+ setCursorPosition(mCursorCol, mCursorRow + getArg0(1));
+ break;
+ // case 'f': "${CSI}${ROW};${COLUMN}f" - Horizontal and Vertical Position (HVP). Grouped with case 'H'.
+ case 'g': // Clear tab_switcher stop
+ switch (getArg0(0)) {
+ case 0:
+ mTabStop[mCursorCol] = false;
+ break;
+ case 3:
+ for (int i = 0; i < mColumns; i++) {
+ mTabStop[i] = false;
+ }
+ break;
+ default:
+ // Specified to have no effect.
+ break;
+ }
+ break;
+ case 'h': // Set Mode
+ doSetMode(true);
+ break;
+ case 'l': // Reset Mode
+ doSetMode(false);
+ break;
+ case 'm': // Esc [ Pn m - character attributes. (can have up to 16 numerical arguments)
+ selectGraphicRendition();
+ break;
+ case 'n': // Esc [ Pn n - ECMA-48 Status Report Commands
+ // sendDeviceAttributes()
+ switch (getArg0(0)) {
+ case 5: // Device status report (DSR):
+ // Answer is ESC [ 0 n (Terminal OK).
+ byte[] dsr = {(byte) 27, (byte) '[', (byte) '0', (byte) 'n'};
+ mSession.write(dsr, 0, dsr.length);
+ break;
+ case 6: // Cursor position report (CPR):
+ // Answer is ESC [ y ; x R, where x,y is
+ // the cursor location.
+ mSession.write(String.format(Locale.US, "\033[%d;%dR", mCursorRow + 1, mCursorCol + 1));
+ break;
+ default:
+ break;
+ }
+ break;
+ case 'r': // "CSI${top};${bottom}r" - set top and bottom Margins (DECSTBM).
+ {
+ // http://www.vt100.net/docs/vt510-rm/DECSTBM
+ // The top margin defaults to 1, the bottom margin defaults to mRows.
+ // The escape sequence numbers top 1..23, but we number top 0..22.
+ // The escape sequence numbers bottom 2..24, and so do we (because we use a zero based numbering
+ // scheme, but we store the first line below the bottom-most scrolling line.
+ // As a result, we adjust the top line by -1, but we leave the bottom line alone.
+ // Also require that top + 2 <= bottom.
+ mTopMargin = Math.max(0, Math.min(getArg0(1) - 1, mRows - 2));
+ mBottomMargin = Math.max(mTopMargin + 2, Math.min(getArg1(mRows), mRows));
+ // DECSTBM moves the cursor to column 1, line 1 of the page respecting origin mode.
+ setCursorPosition(0, 0);
+ }
+ break;
+ case 's':
+ if (isDecsetInternalBitSet(DECSET_BIT_LEFTRIGHT_MARGIN_MODE)) {
+ // Set left and right margins (DECSLRM - http://www.vt100.net/docs/vt510-rm/DECSLRM).
+ mLeftMargin = Math.min(getArg0(1) - 1, mColumns - 2);
+ mRightMargin = Math.max(mLeftMargin + 1, Math.min(getArg1(mColumns), mColumns));
+ // DECSLRM moves the cursor to column 1, line 1 of the page.
+ setCursorPosition(0, 0);
+ } else {
+ // Save cursor (ANSI.SYS), available only when DECLRMM is disabled.
+ saveCursor();
+ }
+ break;
+ case 't': // Window manipulation (from dtterm, as well as extensions)
+ switch (getArg0(0)) {
+ case 11: // Report xterm window state. If the xterm window is open (non-iconified), it returns CSI 1 t .
+ mSession.write("\033[1t");
+ break;
+ case 13: // Report xterm window position. Result is CSI 3 ; x ; y t
+ mSession.write("\033[3;0;0t");
+ break;
+ case 14: // Report xterm window in pixels. Result is CSI 4 ; height ; width t
+ // We just report characters time 12 here.
+ mSession.write(String.format(Locale.US, "\033[4;%d;%dt", mRows * 12, mColumns * 12));
+ break;
+ case 18: // Report the size of the text area in characters. Result is CSI 8 ; height ; width t
+ mSession.write(String.format(Locale.US, "\033[8;%d;%dt", mRows, mColumns));
+ break;
+ case 19: // Report the size of the screen in characters. Result is CSI 9 ; height ; width t
+ // We report the same size as the view, since it's the view really isn't resizable from the shell.
+ mSession.write(String.format(Locale.US, "\033[9;%d;%dt", mRows, mColumns));
+ break;
+ case 20: // Report xterm windows icon label. Result is OSC L label ST. Disabled due to security concerns:
+ mSession.write("\033]LIconLabel\033\\");
+ break;
+ case 21: // Report xterm windows title. Result is OSC l label ST. Disabled due to security concerns:
+ mSession.write("\033]l\033\\");
+ break;
+ case 22:
+ // 22;0 -> Save xterm icon and window title on stack.
+ // 22;1 -> Save xterm icon title on stack.
+ // 22;2 -> Save xterm window title on stack.
+ mTitleStack.push(mTitle);
+ if (mTitleStack.size() > 20) {
+ // Limit size
+ mTitleStack.remove(0);
+ }
+ break;
+ case 23: // Like 22 above but restore from stack.
+ if (!mTitleStack.isEmpty()) setTitle(mTitleStack.pop());
+ break;
+ default:
+ // Ignore window manipulation.
+ break;
+ }
+ break;
+ case 'u': // Restore cursor (ANSI.SYS).
+ restoreCursor();
+ break;
+ case ' ':
+ continueSequence(ESC_CSI_ARGS_SPACE);
+ break;
+ default:
+ parseArg(b);
+ break;
+ }
+ }
+
+ /** Select Graphic Rendition (SGR) - see http://en.wikipedia.org/wiki/ANSI_escape_code#graphics. */
+ private void selectGraphicRendition() {
+ if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1;
+ for (int i = 0; i <= mArgIndex; i++) {
+ int code = mArgs[i];
+ if (code < 0) {
+ if (mArgIndex > 0) {
+ continue;
+ } else {
+ code = 0;
+ }
+ }
+ if (code == 0) { // reset
+ mForeColor = TextStyle.COLOR_INDEX_FOREGROUND;
+ mBackColor = TextStyle.COLOR_INDEX_BACKGROUND;
+ mEffect = 0;
+ } else if (code == 1) {
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_BOLD;
+ } else if (code == 2) {
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_DIM;
+ } else if (code == 3) {
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_ITALIC;
+ } else if (code == 4) {
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE;
+ } else if (code == 5) {
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_BLINK;
+ } else if (code == 7) {
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_INVERSE;
+ } else if (code == 8) {
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE;
+ } else if (code == 9) {
+ mEffect |= TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH;
+ } else if (code == 10) {
+ // Exit alt charset (TERM=linux) - ignore.
+ } else if (code == 11) {
+ // Enter alt charset (TERM=linux) - ignore.
+ } else if (code == 22) { // Normal color or intensity, neither bright, bold nor faint.
+ mEffect &= ~(TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_DIM);
+ } else if (code == 23) { // not italic, but rarely used as such; clears standout with TERM=screen
+ mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_ITALIC;
+ } else if (code == 24) { // underline: none
+ mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE;
+ } else if (code == 25) { // blink: none
+ mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_BLINK;
+ } else if (code == 27) { // image: positive
+ mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_INVERSE;
+ } else if (code == 28) {
+ mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE;
+ } else if (code == 29) {
+ mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH;
+ } else if (code >= 30 && code <= 37) {
+ mForeColor = code - 30;
+ } else if (code == 38 || code == 48) {
+ // Extended set foreground(38)/background (48) color.
+ // This is followed by either "2;$R;$G;$B" to set a 24-bit color or
+ // "5;$INDEX" to set an indexed color.
+ if (i + 2 > mArgIndex) continue;
+ int firstArg = mArgs[i + 1];
+ if (firstArg == 2) {
+ if (i + 4 > mArgIndex) {
+ Log.w(EmulatorDebug.LOG_TAG, "Too few CSI" + code + ";2 RGB arguments");
+ } else {
+ int red = mArgs[i + 2], green = mArgs[i + 3], blue = mArgs[i + 4];
+ if (red < 0 || green < 0 || blue < 0 || red > 255 || green > 255 || blue > 255) {
+ finishSequenceAndLogError("Invalid RGB: " + red + "," + green + "," + blue);
+ } else {
+ int argbColor = 0xff000000 | (red << 16) | (green << 8) | blue;
+ if (code == 38) {
+ mForeColor = argbColor;
+ } else {
+ mBackColor = argbColor;
+ }
+ }
+ i += 4; // "2;P_r;P_g;P_r"
+ }
+ } else if (firstArg == 5) {
+ int color = mArgs[i + 2];
+ i += 2; // "5;P_s"
+ if (color >= 0 && color < TextStyle.NUM_INDEXED_COLORS) {
+ if (code == 38) {
+ mForeColor = color;
+ } else {
+ mBackColor = color;
+ }
+ } else {
+ if (LOG_ESCAPE_SEQUENCES) Log.w(EmulatorDebug.LOG_TAG, "Invalid color index: " + color);
+ }
+ } else {
+ finishSequenceAndLogError("Invalid ISO-8613-3 SGR first argument: " + firstArg);
+ }
+ } else if (code == 39) { // Set default foreground color.
+ mForeColor = TextStyle.COLOR_INDEX_FOREGROUND;
+ } else if (code >= 40 && code <= 47) { // Set background color.
+ mBackColor = code - 40;
+ } else if (code == 49) { // Set default background color.
+ mBackColor = TextStyle.COLOR_INDEX_BACKGROUND;
+ } else if (code >= 90 && code <= 97) { // Bright foreground colors (aixterm codes).
+ mForeColor = code - 90 + 8;
+ } else if (code >= 100 && code <= 107) { // Bright background color (aixterm codes).
+ mBackColor = code - 100 + 8;
+ } else {
+ if (LOG_ESCAPE_SEQUENCES)
+ Log.w(EmulatorDebug.LOG_TAG, String.format("SGR unknown code %d", code));
+ }
+ }
+ }
+
+ private void doOsc(int b) {
+ switch (b) {
+ case 7: // Bell.
+ doOscSetTextParameters("\007");
+ break;
+ case 27: // Escape.
+ continueSequence(ESC_OSC_ESC);
+ break;
+ default:
+ collectOSCArgs(b);
+ break;
+ }
+ }
+
+ private void doOscEsc(int b) {
+ switch (b) {
+ case '\\':
+ doOscSetTextParameters("\033\\");
+ break;
+ default:
+ // The ESC character was not followed by a \, so insert the ESC and
+ // the current character in arg buffer.
+ collectOSCArgs(27);
+ collectOSCArgs(b);
+ continueSequence(ESC_OSC);
+ break;
+ }
+ }
+
+ /** An Operating System Controls (OSC) Set Text Parameters. May come here from BEL or ST. */
+ private void doOscSetTextParameters(String bellOrStringTerminator) {
+ int value = -1;
+ String textParameter = "";
+ // Extract initial $value from initial "$value;..." string.
+ for (int mOSCArgTokenizerIndex = 0; mOSCArgTokenizerIndex < mOSCOrDeviceControlArgs.length(); mOSCArgTokenizerIndex++) {
+ char b = mOSCOrDeviceControlArgs.charAt(mOSCArgTokenizerIndex);
+ if (b == ';') {
+ textParameter = mOSCOrDeviceControlArgs.substring(mOSCArgTokenizerIndex + 1);
+ break;
+ } else if (b >= '0' && b <= '9') {
+ value = ((value < 0) ? 0 : value * 10) + (b - '0');
+ } else {
+ unknownSequence(b);
+ return;
+ }
+ }
+
+ switch (value) {
+ case 0: // Change icon name and window title to T.
+ case 1: // Change icon name to T.
+ case 2: // Change window title to T.
+ setTitle(textParameter);
+ break;
+ case 4:
+ // P s = 4 ; c ; spec → Change Color Number c to the color specified by spec. This can be a name or RGB
+ // specification as per XParseColor. Any number of c name pairs may be given. The color numbers correspond
+ // to the ANSI colors 0-7, their bright versions 8-15, and if supported, the remainder of the 88-color or
+ // 256-color table.
+ // If a "?" is given rather than a name or RGB specification, xterm replies with a control sequence of the
+ // same form which can be used to set the corresponding color. Because more than one pair of color number
+ // and specification can be given in one control sequence, xterm can make more than one reply.
+ int colorIndex = -1;
+ int parsingPairStart = -1;
+ for (int i = 0; ; i++) {
+ boolean endOfInput = i == textParameter.length();
+ char b = endOfInput ? ';' : textParameter.charAt(i);
+ if (b == ';') {
+ if (parsingPairStart < 0) {
+ parsingPairStart = i + 1;
+ } else {
+ if (colorIndex < 0 || colorIndex > 255) {
+ unknownSequence(b);
+ return;
+ } else {
+ mColors.tryParseColor(colorIndex, textParameter.substring(parsingPairStart, i));
+ mSession.onColorsChanged();
+ colorIndex = -1;
+ parsingPairStart = -1;
+ }
+ }
+ } else if (parsingPairStart >= 0) {
+ // We have passed a color index and are now going through color spec.
+ } else if (parsingPairStart < 0 && (b >= '0' && b <= '9')) {
+ colorIndex = ((colorIndex < 0) ? 0 : colorIndex * 10) + (b - '0');
+ } else {
+ unknownSequence(b);
+ return;
+ }
+ if (endOfInput) break;
+ }
+ break;
+ case 10: // Set foreground color.
+ case 11: // Set background color.
+ case 12: // Set cursor color.
+ int specialIndex = TextStyle.COLOR_INDEX_FOREGROUND + (value - 10);
+ int lastSemiIndex = 0;
+ for (int charIndex = 0; ; charIndex++) {
+ boolean endOfInput = charIndex == textParameter.length();
+ if (endOfInput || textParameter.charAt(charIndex) == ';') {
+ try {
+ String colorSpec = textParameter.substring(lastSemiIndex, charIndex);
+ if ("?".equals(colorSpec)) {
+ // Report current color in the same format xterm and gnome-terminal does.
+ int rgb = mColors.mCurrentColors[specialIndex];
+ int r = (65535 * ((rgb & 0x00FF0000) >> 16)) / 255;
+ int g = (65535 * ((rgb & 0x0000FF00) >> 8)) / 255;
+ int b = (65535 * ((rgb & 0x000000FF))) / 255;
+ mSession.write("\033]" + value + ";rgb:" + String.format(Locale.US, "%04x", r) + "/" + String.format(Locale.US, "%04x", g) + "/"
+ + String.format(Locale.US, "%04x", b) + bellOrStringTerminator);
+ } else {
+ mColors.tryParseColor(specialIndex, colorSpec);
+ mSession.onColorsChanged();
+ }
+ specialIndex++;
+ if (endOfInput || (specialIndex > TextStyle.COLOR_INDEX_CURSOR) || ++charIndex >= textParameter.length())
+ break;
+ lastSemiIndex = charIndex;
+ } catch (NumberFormatException e) {
+ // Ignore.
+ }
+ }
+ }
+ break;
+ case 52: // Manipulate Selection Data. Skip the optional first selection parameter(s).
+ int startIndex = textParameter.indexOf(";") + 1;
+ try {
+ String clipboardText = new String(Base64.decode(textParameter.substring(startIndex), 0), StandardCharsets.UTF_8);
+ mSession.clipboardText(clipboardText);
+ } catch (Exception e) {
+ Log.e(EmulatorDebug.LOG_TAG, "OSC Manipulate selection, invalid string '" + textParameter + "");
+ }
+ break;
+ case 104:
+ // "104;$c" → Reset Color Number $c. It is reset to the color specified by the corresponding X
+ // resource. Any number of c parameters may be given. These parameters correspond to the ANSI colors 0-7,
+ // their bright versions 8-15, and if supported, the remainder of the 88-color or 256-color table. If no
+ // parameters are given, the entire table will be reset.
+ if (textParameter.isEmpty()) {
+ mColors.reset();
+ mSession.onColorsChanged();
+ } else {
+ int lastIndex = 0;
+ for (int charIndex = 0; ; charIndex++) {
+ boolean endOfInput = charIndex == textParameter.length();
+ if (endOfInput || textParameter.charAt(charIndex) == ';') {
+ try {
+ int colorToReset = Integer.parseInt(textParameter.substring(lastIndex, charIndex));
+ mColors.reset(colorToReset);
+ mSession.onColorsChanged();
+ if (endOfInput) break;
+ charIndex++;
+ lastIndex = charIndex;
+ } catch (NumberFormatException e) {
+ // Ignore.
+ }
+ }
+ }
+ }
+ break;
+ case 110: // Reset foreground color.
+ case 111: // Reset background color.
+ case 112: // Reset cursor color.
+ mColors.reset(TextStyle.COLOR_INDEX_FOREGROUND + (value - 110));
+ mSession.onColorsChanged();
+ break;
+ case 119: // Reset highlight color.
+ break;
+ default:
+ unknownParameter(value);
+ break;
+ }
+ finishSequence();
+ }
+
+ private void blockClear(int sx, int sy, int w) {
+ blockClear(sx, sy, w, 1);
+ }
+
+ private void blockClear(int sx, int sy, int w, int h) {
+ mScreen.blockSet(sx, sy, w, h, ' ', getStyle());
+ }
+
+ private long getStyle() {
+ return TextStyle.encode(mForeColor, mBackColor, mEffect);
+ }
+
+ /** "CSI P_m h" for set or "CSI P_m l" for reset ANSI mode. */
+ private void doSetMode(boolean newValue) {
+ int modeBit = getArg0(0);
+ switch (modeBit) {
+ case 4: // Set="Insert Mode". Reset="Replace Mode". (IRM).
+ mInsertMode = newValue;
+ break;
+ case 20: // Normal Linefeed (LNM).
+ unknownParameter(modeBit);
+ // http://www.vt100.net/docs/vt510-rm/LNM
+ break;
+ case 34:
+ // Normal cursor visibility - when using TERM=screen, see
+ // http://www.gnu.org/software/screen/manual/html_node/Control-Sequences.html
+ break;
+ default:
+ unknownParameter(modeBit);
+ break;
+ }
+ }
+
+ /**
+ * NOTE: The parameters of this function respect the {@link #DECSET_BIT_ORIGIN_MODE}. Use
+ * {@link #setCursorRowCol(int, int)} for absolute pos.
+ */
+ private void setCursorPosition(int x, int y) {
+ boolean originMode = isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE);
+ int effectiveTopMargin = originMode ? mTopMargin : 0;
+ int effectiveBottomMargin = originMode ? mBottomMargin : mRows;
+ int effectiveLeftMargin = originMode ? mLeftMargin : 0;
+ int effectiveRightMargin = originMode ? mRightMargin : mColumns;
+ int newRow = Math.max(effectiveTopMargin, Math.min(effectiveTopMargin + y, effectiveBottomMargin - 1));
+ int newCol = Math.max(effectiveLeftMargin, Math.min(effectiveLeftMargin + x, effectiveRightMargin - 1));
+ setCursorRowCol(newRow, newCol);
+ }
+
+ private void scrollDownOneLine() {
+ mScrollCounter++;
+ if (mLeftMargin != 0 || mRightMargin != mColumns) {
+ // Horizontal margin: Do not put anything into scroll history, just non-margin part of screen up.
+ mScreen.blockCopy(mLeftMargin, mTopMargin + 1, mRightMargin - mLeftMargin, mBottomMargin - mTopMargin - 1, mLeftMargin, mTopMargin);
+ // .. and blank bottom row between margins:
+ mScreen.blockSet(mLeftMargin, mBottomMargin - 1, mRightMargin - mLeftMargin, 1, ' ', mEffect);
+ } else {
+ mScreen.scrollDownOneLine(mTopMargin, mBottomMargin, getStyle());
+ }
+ }
+
+ /** Process the next ASCII character of a parameter. */
+ private void parseArg(int b) {
+ if (b >= '0' && b <= '9') {
+ if (mArgIndex < mArgs.length) {
+ int oldValue = mArgs[mArgIndex];
+ int thisDigit = b - '0';
+ int value;
+ if (oldValue >= 0) {
+ value = oldValue * 10 + thisDigit;
+ } else {
+ value = thisDigit;
+ }
+ mArgs[mArgIndex] = value;
+ }
+ continueSequence(mEscapeState);
+ } else if (b == ';') {
+ if (mArgIndex < mArgs.length) {
+ mArgIndex++;
+ }
+ continueSequence(mEscapeState);
+ } else {
+ unknownSequence(b);
+ }
+ }
+
+ private int getArg0(int defaultValue) {
+ return getArg(0, defaultValue, true);
+ }
+
+ private int getArg1(int defaultValue) {
+ return getArg(1, defaultValue, true);
+ }
+
+ private int getArg(int index, int defaultValue, boolean treatZeroAsDefault) {
+ int result = mArgs[index];
+ if (result < 0 || (result == 0 && treatZeroAsDefault)) {
+ result = defaultValue;
+ }
+ return result;
+ }
+
+ private void collectOSCArgs(int b) {
+ if (mOSCOrDeviceControlArgs.length() < MAX_OSC_STRING_LENGTH) {
+ mOSCOrDeviceControlArgs.appendCodePoint(b);
+ continueSequence(mEscapeState);
+ } else {
+ unknownSequence(b);
+ }
+ }
+
+ private void unimplementedSequence(int b) {
+ logError("Unimplemented sequence char '" + (char) b + "' (U+" + String.format("%04x", b) + ")");
+ finishSequence();
+ }
+
+ private void unknownSequence(int b) {
+ logError("Unknown sequence char '" + (char) b + "' (numeric value=" + b + ")");
+ finishSequence();
+ }
+
+ private void unknownParameter(int parameter) {
+ logError("Unknown parameter: " + parameter);
+ finishSequence();
+ }
+
+ private void logError(String errorType) {
+ if (LOG_ESCAPE_SEQUENCES) {
+ StringBuilder buf = new StringBuilder();
+ buf.append(errorType);
+ buf.append(", escapeState=");
+ buf.append(mEscapeState);
+ boolean firstArg = true;
+ if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1;
+ for (int i = 0; i <= mArgIndex; i++) {
+ int value = mArgs[i];
+ if (value >= 0) {
+ if (firstArg) {
+ firstArg = false;
+ buf.append(", args={");
+ } else {
+ buf.append(',');
+ }
+ buf.append(value);
+ }
+ }
+ if (!firstArg) buf.append('}');
+ finishSequenceAndLogError(buf.toString());
+ }
+ }
+
+ private void finishSequenceAndLogError(String error) {
+ if (LOG_ESCAPE_SEQUENCES) Log.w(EmulatorDebug.LOG_TAG, error);
+ finishSequence();
+ }
+
+ private void finishSequence() {
+ mEscapeState = ESC_NONE;
+ }
+
+ /**
+ * Send a Unicode code point to the screen.
+ *
+ * @param codePoint The code point of the character to display
+ */
+ private void emitCodePoint(int codePoint) {
+ if (mUseLineDrawingUsesG0 ? mUseLineDrawingG0 : mUseLineDrawingG1) {
+ // http://www.vt100.net/docs/vt102-ug/table5-15.html.
+ switch (codePoint) {
+ case '_':
+ codePoint = ' '; // Blank.
+ break;
+ case '`':
+ codePoint = '◆'; // Diamond.
+ break;
+ case '0':
+ codePoint = '█'; // Solid block;
+ break;
+ case 'a':
+ codePoint = '▒'; // Checker board.
+ break;
+ case 'b':
+ codePoint = '␉'; // Horizontal tab_switcher.
+ break;
+ case 'c':
+ codePoint = '␌'; // Form feed.
+ break;
+ case 'd':
+ codePoint = '\r'; // Carriage return.
+ break;
+ case 'e':
+ codePoint = '␊'; // Linefeed.
+ break;
+ case 'f':
+ codePoint = '°'; // Degree.
+ break;
+ case 'g':
+ codePoint = '±'; // Plus-minus.
+ break;
+ case 'h':
+ codePoint = '\n'; // Newline.
+ break;
+ case 'i':
+ codePoint = '␋'; // Vertical tab_switcher.
+ break;
+ case 'j':
+ codePoint = '┘'; // Lower right corner.
+ break;
+ case 'k':
+ codePoint = '┐'; // Upper right corner.
+ break;
+ case 'l':
+ codePoint = '┌'; // Upper left corner.
+ break;
+ case 'm':
+ codePoint = '└'; // Left left corner.
+ break;
+ case 'n':
+ codePoint = '┼'; // Crossing lines.
+ break;
+ case 'o':
+ codePoint = '⎺'; // Horizontal line - scan 1.
+ break;
+ case 'p':
+ codePoint = '⎻'; // Horizontal line - scan 3.
+ break;
+ case 'q':
+ codePoint = '─'; // Horizontal line - scan 5.
+ break;
+ case 'r':
+ codePoint = '⎼'; // Horizontal line - scan 7.
+ break;
+ case 's':
+ codePoint = '⎽'; // Horizontal line - scan 9.
+ break;
+ case 't':
+ codePoint = '├'; // T facing rightwards.
+ break;
+ case 'u':
+ codePoint = '┤'; // T facing leftwards.
+ break;
+ case 'v':
+ codePoint = '┴'; // T facing upwards.
+ break;
+ case 'w':
+ codePoint = '┬'; // T facing downwards.
+ break;
+ case 'x':
+ codePoint = '│'; // Vertical line.
+ break;
+ case 'y':
+ codePoint = '≤'; // Less than or equal to.
+ break;
+ case 'z':
+ codePoint = '≥'; // Greater than or equal to.
+ break;
+ case '{':
+ codePoint = 'π'; // Pi.
+ break;
+ case '|':
+ codePoint = '≠'; // Not equal to.
+ break;
+ case '}':
+ codePoint = '£'; // UK pound.
+ break;
+ case '~':
+ codePoint = '·'; // Centered dot.
+ break;
+ }
+ }
+
+ final boolean autoWrap = isDecsetInternalBitSet(DECSET_BIT_AUTOWRAP);
+ final int displayWidth = WcWidth.width(codePoint);
+ final boolean cursorInLastColumn = mCursorCol == mRightMargin - 1;
+
+ if (autoWrap) {
+ if (cursorInLastColumn && ((mAboutToAutoWrap && displayWidth == 1) || displayWidth == 2)) {
+ mScreen.setLineWrap(mCursorRow);
+ mCursorCol = mLeftMargin;
+ if (mCursorRow + 1 < mBottomMargin) {
+ mCursorRow++;
+ } else {
+ scrollDownOneLine();
+ }
+ }
+ } else if (cursorInLastColumn && displayWidth == 2) {
+ // The behaviour when a wide character is output with cursor in the last column when
+ // autowrap is disabled is not obvious - it's ignored here.
+ return;
+ }
+
+ if (mInsertMode && displayWidth > 0) {
+ // Move character to right one space.
+ int destCol = mCursorCol + displayWidth;
+ if (destCol < mRightMargin)
+ mScreen.blockCopy(mCursorCol, mCursorRow, mRightMargin - destCol, 1, destCol, mCursorRow);
+ }
+
+ int offsetDueToCombiningChar = ((displayWidth <= 0 && mCursorCol > 0 && !mAboutToAutoWrap) ? 1 : 0);
+ mScreen.setChar(mCursorCol - offsetDueToCombiningChar, mCursorRow, codePoint, getStyle());
+
+ if (autoWrap && displayWidth > 0)
+ mAboutToAutoWrap = (mCursorCol == mRightMargin - displayWidth);
+
+ mCursorCol = Math.min(mCursorCol + displayWidth, mRightMargin - 1);
+ }
+
+ private void setCursorRow(int row) {
+ mCursorRow = row;
+ mAboutToAutoWrap = false;
+ }
+
+ private void setCursorCol(int col) {
+ mCursorCol = col;
+ mAboutToAutoWrap = false;
+ }
+
+ /** Set the cursor mode, but limit it to margins if {@link #DECSET_BIT_ORIGIN_MODE} is enabled. */
+ private void setCursorColRespectingOriginMode(int col) {
+ setCursorPosition(col, mCursorRow);
+ }
+
+ /** TODO: Better name, distinguished from {@link #setCursorPosition(int, int)} by not regarding origin mode. */
+ private void setCursorRowCol(int row, int col) {
+ mCursorRow = Math.max(0, Math.min(row, mRows - 1));
+ mCursorCol = Math.max(0, Math.min(col, mColumns - 1));
+ mAboutToAutoWrap = false;
+ }
+
+ public int getScrollCounter() {
+ return mScrollCounter;
+ }
+
+ public void clearScrollCounter() {
+ mScrollCounter = 0;
+ }
+
+ /** Reset terminal state so user can interact with it regardless of present state. */
+ public void reset() {
+ mCursorStyle = CURSOR_STYLE_BLOCK;
+ mArgIndex = 0;
+ mContinueSequence = false;
+ mEscapeState = ESC_NONE;
+ mInsertMode = false;
+ mTopMargin = mLeftMargin = 0;
+ mBottomMargin = mRows;
+ mRightMargin = mColumns;
+ mAboutToAutoWrap = false;
+ mForeColor = mSavedStateMain.mSavedForeColor = mSavedStateAlt.mSavedForeColor = TextStyle.COLOR_INDEX_FOREGROUND;
+ mBackColor = mSavedStateMain.mSavedBackColor = mSavedStateAlt.mSavedBackColor = TextStyle.COLOR_INDEX_BACKGROUND;
+ setDefaultTabStops();
+
+ mUseLineDrawingG0 = mUseLineDrawingG1 = false;
+ mUseLineDrawingUsesG0 = true;
+
+ mSavedStateMain.mSavedCursorRow = mSavedStateMain.mSavedCursorCol = mSavedStateMain.mSavedEffect = mSavedStateMain.mSavedDecFlags = 0;
+ mSavedStateAlt.mSavedCursorRow = mSavedStateAlt.mSavedCursorCol = mSavedStateAlt.mSavedEffect = mSavedStateAlt.mSavedDecFlags = 0;
+ mCurrentDecSetFlags = 0;
+ // Initial wrap-around is not accurate but makes terminal more useful, especially on a small screen:
+ setDecsetinternalBit(DECSET_BIT_AUTOWRAP, true);
+ setDecsetinternalBit(DECSET_BIT_SHOWING_CURSOR, true);
+ mSavedDecSetFlags = mSavedStateMain.mSavedDecFlags = mSavedStateAlt.mSavedDecFlags = mCurrentDecSetFlags;
+
+ // XXX: Should we set terminal driver back to IUTF8 with termios?
+ mUtf8Index = mUtf8ToFollow = 0;
+
+ mColors.reset();
+ mSession.onColorsChanged();
+ }
+
+ public String getSelectedText(int x1, int y1, int x2, int y2) {
+ return mScreen.getSelectedText(x1, y1, x2, y2);
+ }
+
+ /** Get the terminal session's title (null if not set). */
+ public String getTitle() {
+ return mTitle;
+ }
+
+ /** Change the terminal session's title. */
+ private void setTitle(String newTitle) {
+ String oldTitle = mTitle;
+ mTitle = newTitle;
+ if (!Objects.equals(oldTitle, newTitle)) {
+ mSession.titleChanged(oldTitle, newTitle);
+ }
+ }
+
+ /** If DECSET 2004 is set, prefix paste with "\033[200~" and suffix with "\033[201~". */
+ public void paste(String text) {
+ // First: Always remove escape key and C1 control characters [0x80,0x9F]:
+ text = text.replaceAll("(\u001B|[\u0080-\u009F])", "");
+ // Then: Implement bracketed paste mode if enabled:
+ boolean bracketed = isDecsetInternalBitSet(DECSET_BIT_BRACKETED_PASTE_MODE);
+ if (bracketed) mSession.write("\033[200~");
+ mSession.write(text);
+ if (bracketed) mSession.write("\033[201~");
+ }
+
+ /** http://www.vt100.net/docs/vt510-rm/DECSC */
+ static final class SavedScreenState {
+ /** Saved state of the cursor position, Used to implement the save/restore cursor position escape sequences. */
+ int mSavedCursorRow, mSavedCursorCol;
+ int mSavedEffect, mSavedForeColor, mSavedBackColor;
+ int mSavedDecFlags;
+ boolean mUseLineDrawingG0, mUseLineDrawingG1, mUseLineDrawingUsesG0 = true;
+ }
+
+ @Override
+ public String toString() {
+ return "TerminalEmulator[size=" + mScreen.mColumns + "x" + mScreen.mScreenRows + ", margins={" + mTopMargin + "," + mRightMargin + "," + mBottomMargin
+ + "," + mLeftMargin + "}]";
+ }
+
+}
diff --git a/app/src/main/java/io/neoterm/terminal/TerminalOutput.java b/app/src/main/java/io/neoterm/terminal/TerminalOutput.java
new file mode 100755
index 0000000..8b742d5
--- /dev/null
+++ b/app/src/main/java/io/neoterm/terminal/TerminalOutput.java
@@ -0,0 +1,28 @@
+package io.neoterm.terminal;
+
+import java.nio.charset.StandardCharsets;
+
+/** A client which receives callbacks from events triggered by feeding input to a {@link TerminalEmulator}. */
+public abstract class TerminalOutput {
+
+ /** Write a string using the UTF-8 encoding to the terminal client. */
+ public final void write(String data) {
+ byte[] bytes = data.getBytes(StandardCharsets.UTF_8);
+ write(bytes, 0, bytes.length);
+ }
+
+ /** Write bytes to the terminal client. */
+ public abstract void write(byte[] data, int offset, int count);
+
+ /** Notify the terminal client that the terminal title has changed. */
+ public abstract void titleChanged(String oldTitle, String newTitle);
+
+ /** Notify the terminal client that the terminal title has changed. */
+ public abstract void clipboardText(String text);
+
+ /** Notify the terminal client that a bell character (ASCII 7, bell, BEL, \a, ^G)) has been received. */
+ public abstract void onBell();
+
+ public abstract void onColorsChanged();
+
+}
diff --git a/app/src/main/java/io/neoterm/terminal/TerminalRow.java b/app/src/main/java/io/neoterm/terminal/TerminalRow.java
new file mode 100755
index 0000000..93186e6
--- /dev/null
+++ b/app/src/main/java/io/neoterm/terminal/TerminalRow.java
@@ -0,0 +1,232 @@
+package io.neoterm.terminal;
+
+import java.util.Arrays;
+
+/**
+ * A row in a terminal, composed of a fixed number of cells.
+ *
+ * The text in the row is stored in a char[] array, {@link #mText}, for quick access during rendering.
+ */
+public final class TerminalRow {
+
+ private static final float SPARE_CAPACITY_FACTOR = 1.5f;
+
+ /** The number of columns in this terminal row. */
+ private final int mColumns;
+ /** The text filling this terminal row. */
+ public char[] mText;
+ /** The number of java char:s used in {@link #mText}. */
+ private short mSpaceUsed;
+ /** If this row has been line wrapped due to text output at the end of line. */
+ boolean mLineWrap;
+ /** The style bits of each cell in the row. See {@link TextStyle}. */
+ final long[] mStyle;
+
+ /** Construct a blank row (containing only whitespace, ' ') with a specified style. */
+ public TerminalRow(int columns, long style) {
+ mColumns = columns;
+ mText = new char[(int) (SPARE_CAPACITY_FACTOR * columns)];
+ mStyle = new long[columns];
+ clear(style);
+ }
+
+ /** NOTE: The sourceX2 is exclusive. */
+ public void copyInterval(TerminalRow line, int sourceX1, int sourceX2, int destinationX) {
+ final int x1 = line.findStartOfColumn(sourceX1);
+ final int x2 = line.findStartOfColumn(sourceX2);
+ boolean startingFromSecondHalfOfWideChar = (sourceX1 > 0 && line.wideDisplayCharacterStartingAt(sourceX1 - 1));
+ final char[] sourceChars = (this == line) ? Arrays.copyOf(line.mText, line.mText.length) : line.mText;
+ int latestNonCombiningWidth = 0;
+ for (int i = x1; i < x2; i++) {
+ char sourceChar = sourceChars[i];
+ int codePoint = Character.isHighSurrogate(sourceChar) ? Character.toCodePoint(sourceChar, sourceChars[++i]) : sourceChar;
+ if (startingFromSecondHalfOfWideChar) {
+ // Just treat copying second half of wide char as copying whitespace.
+ codePoint = ' ';
+ startingFromSecondHalfOfWideChar = false;
+ }
+ int w = WcWidth.width(codePoint);
+ if (w > 0) {
+ destinationX += latestNonCombiningWidth;
+ sourceX1 += latestNonCombiningWidth;
+ latestNonCombiningWidth = w;
+ }
+ setChar(destinationX, codePoint, line.getStyle(sourceX1));
+ }
+ }
+
+ public int getSpaceUsed() {
+ return mSpaceUsed;
+ }
+
+ /** Note that the column may end of second half of wide character. */
+ public int findStartOfColumn(int column) {
+ if (column == mColumns) return getSpaceUsed();
+
+ int currentColumn = 0;
+ int currentCharIndex = 0;
+ while (true) { // 0<2 1 < 2
+ int newCharIndex = currentCharIndex;
+ char c = mText[newCharIndex++]; // cci=1, cci=2
+ boolean isHigh = Character.isHighSurrogate(c);
+ int codePoint = isHigh ? Character.toCodePoint(c, mText[newCharIndex++]) : c;
+ int wcwidth = WcWidth.width(codePoint); // 1, 2
+ if (wcwidth > 0) {
+ currentColumn += wcwidth;
+ if (currentColumn == column) {
+ while (newCharIndex < mSpaceUsed) {
+ // Skip combining chars.
+ if (Character.isHighSurrogate(mText[newCharIndex])) {
+ if (WcWidth.width(Character.toCodePoint(mText[newCharIndex], mText[newCharIndex + 1])) <= 0) {
+ newCharIndex += 2;
+ } else {
+ break;
+ }
+ } else if (WcWidth.width(mText[newCharIndex]) <= 0) {
+ newCharIndex++;
+ } else {
+ break;
+ }
+ }
+ return newCharIndex;
+ } else if (currentColumn > column) {
+ // Wide column going past end.
+ return currentCharIndex;
+ }
+ }
+ currentCharIndex = newCharIndex;
+ }
+ }
+
+ private boolean wideDisplayCharacterStartingAt(int column) {
+ for (int currentCharIndex = 0, currentColumn = 0; currentCharIndex < mSpaceUsed; ) {
+ char c = mText[currentCharIndex++];
+ int codePoint = Character.isHighSurrogate(c) ? Character.toCodePoint(c, mText[currentCharIndex++]) : c;
+ int wcwidth = WcWidth.width(codePoint);
+ if (wcwidth > 0) {
+ if (currentColumn == column && wcwidth == 2) return true;
+ currentColumn += wcwidth;
+ if (currentColumn > column) return false;
+ }
+ }
+ return false;
+ }
+
+ public void clear(long style) {
+ Arrays.fill(mText, ' ');
+ Arrays.fill(mStyle, style);
+ mSpaceUsed = (short) mColumns;
+ }
+
+ // https://github.com/steven676/Android-Terminal-Emulator/commit/9a47042620bec87617f0b4f5d50568535668fe26
+ public void setChar(int columnToSet, int codePoint, long style) {
+ mStyle[columnToSet] = style;
+
+ final int newCodePointDisplayWidth = WcWidth.width(codePoint);
+ final boolean newIsCombining = newCodePointDisplayWidth <= 0;
+
+ boolean wasExtraColForWideChar = (columnToSet > 0) && wideDisplayCharacterStartingAt(columnToSet - 1);
+
+ if (newIsCombining) {
+ // When standing at second half of wide character and inserting combining:
+ if (wasExtraColForWideChar) columnToSet--;
+ } else {
+ // Check if we are overwriting the second half of a wide character starting at the previous column:
+ if (wasExtraColForWideChar) setChar(columnToSet - 1, ' ', style);
+ // Check if we are overwriting the first half of a wide character starting at the next column:
+ boolean overwritingWideCharInNextColumn = newCodePointDisplayWidth == 2 && wideDisplayCharacterStartingAt(columnToSet + 1);
+ if (overwritingWideCharInNextColumn) setChar(columnToSet + 1, ' ', style);
+ }
+
+ char[] text = mText;
+ final int oldStartOfColumnIndex = findStartOfColumn(columnToSet);
+ final int oldCodePointDisplayWidth = WcWidth.width(text, oldStartOfColumnIndex);
+
+ // Get the number of elements in the mText array this column uses now
+ int oldCharactersUsedForColumn;
+ if (columnToSet + oldCodePointDisplayWidth < mColumns) {
+ oldCharactersUsedForColumn = findStartOfColumn(columnToSet + oldCodePointDisplayWidth) - oldStartOfColumnIndex;
+ } else {
+ // Last character.
+ oldCharactersUsedForColumn = mSpaceUsed - oldStartOfColumnIndex;
+ }
+
+ // Find how many chars this column will need
+ int newCharactersUsedForColumn = Character.charCount(codePoint);
+ if (newIsCombining) {
+ // Combining characters are added to the contents of the column instead of overwriting them, so that they
+ // modify the existing contents.
+ // FIXME: Put a limit of combining characters.
+ // FIXME: Unassigned characters also get width=0.
+ newCharactersUsedForColumn += oldCharactersUsedForColumn;
+ }
+
+ int oldNextColumnIndex = oldStartOfColumnIndex + oldCharactersUsedForColumn;
+ int newNextColumnIndex = oldStartOfColumnIndex + newCharactersUsedForColumn;
+
+ final int javaCharDifference = newCharactersUsedForColumn - oldCharactersUsedForColumn;
+ if (javaCharDifference > 0) {
+ // Shift the rest of the line right.
+ int oldCharactersAfterColumn = mSpaceUsed - oldNextColumnIndex;
+ if (mSpaceUsed + javaCharDifference > text.length) {
+ // We need to grow the array
+ char[] newText = new char[text.length + mColumns];
+ System.arraycopy(text, 0, newText, 0, oldStartOfColumnIndex + oldCharactersUsedForColumn);
+ System.arraycopy(text, oldNextColumnIndex, newText, newNextColumnIndex, oldCharactersAfterColumn);
+ mText = text = newText;
+ } else {
+ System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, oldCharactersAfterColumn);
+ }
+ } else if (javaCharDifference < 0) {
+ // Shift the rest of the line left.
+ System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - oldNextColumnIndex);
+ }
+ mSpaceUsed += javaCharDifference;
+
+ // Store char. A combining character is stored at the end of the existing contents so that it modifies them:
+ //noinspection ResultOfMethodCallIgnored - since we already now how many java chars is used.
+ Character.toChars(codePoint, text, oldStartOfColumnIndex + (newIsCombining ? oldCharactersUsedForColumn : 0));
+
+ if (oldCodePointDisplayWidth == 2 && newCodePointDisplayWidth == 1) {
+ // Replace second half of wide char with a space. Which mean that we actually add a ' ' java character.
+ if (mSpaceUsed + 1 > text.length) {
+ char[] newText = new char[text.length + mColumns];
+ System.arraycopy(text, 0, newText, 0, newNextColumnIndex);
+ System.arraycopy(text, newNextColumnIndex, newText, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex);
+ mText = text = newText;
+ } else {
+ System.arraycopy(text, newNextColumnIndex, text, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex);
+ }
+ text[newNextColumnIndex] = ' ';
+
+ ++mSpaceUsed;
+ } else if (oldCodePointDisplayWidth == 1 && newCodePointDisplayWidth == 2) {
+ if (columnToSet == mColumns - 1) {
+ throw new IllegalArgumentException("Cannot put wide character in last column");
+ } else if (columnToSet == mColumns - 2) {
+ // Truncate the line to the second part of this wide char:
+ mSpaceUsed = (short) newNextColumnIndex;
+ } else {
+ // Overwrite the contents of the next column, which mean we actually remove java characters. Due to the
+ // check at the beginning of this method we know that we are not overwriting a wide char.
+ int newNextNextColumnIndex = newNextColumnIndex + (Character.isHighSurrogate(mText[newNextColumnIndex]) ? 2 : 1);
+ int nextLen = newNextNextColumnIndex - newNextColumnIndex;
+
+ // Shift the array leftwards.
+ System.arraycopy(text, newNextNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - newNextNextColumnIndex);
+ mSpaceUsed -= nextLen;
+ }
+ }
+ }
+
+ boolean isBlank() {
+ for (int charIndex = 0, charLen = getSpaceUsed(); charIndex < charLen; charIndex++)
+ if (mText[charIndex] != ' ') return false;
+ return true;
+ }
+
+ public final long getStyle(int column) {
+ return mStyle[column];
+ }
+
+}
diff --git a/app/src/main/java/io/neoterm/terminal/TerminalSession.java b/app/src/main/java/io/neoterm/terminal/TerminalSession.java
new file mode 100755
index 0000000..b59e191
--- /dev/null
+++ b/app/src/main/java/io/neoterm/terminal/TerminalSession.java
@@ -0,0 +1,342 @@
+package io.neoterm.terminal;
+
+import android.annotation.SuppressLint;
+import android.os.Handler;
+import android.os.Message;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.system.OsConstants;
+import android.util.Log;
+
+import java.io.FileDescriptor;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+/**
+ * A terminal session, consisting of a process coupled to a terminal interface.
+ *
+ * The subprocess will be executed by the constructor, and when the size is made known by a call to
+ * {@link #updateSize(int, int)} terminal emulation will begin and threads will be spawned to handle the subprocess I/O.
+ * All terminal emulation and callback methods will be performed on the main thread.
+ *
+ * The child process may be exited forcefully by using the {@link #finishIfRunning()} method.
+ *
+ * NOTE: The terminal session may outlive the EmulatorView, so be careful with callbacks!
+ */
+public final class TerminalSession extends TerminalOutput {
+
+ /** Callback to be invoked when a {@link TerminalSession} changes. */
+ public interface SessionChangedCallback {
+ void onTextChanged(TerminalSession changedSession);
+
+ void onTitleChanged(TerminalSession changedSession);
+
+ void onSessionFinished(TerminalSession finishedSession);
+
+ void onClipboardText(TerminalSession session, String text);
+
+ void onBell(TerminalSession session);
+
+ void onColorsChanged(TerminalSession session);
+
+ }
+
+ private static FileDescriptor wrapFileDescriptor(int fileDescriptor) {
+ FileDescriptor result = new FileDescriptor();
+ try {
+ Field descriptorField;
+ try {
+ descriptorField = FileDescriptor.class.getDeclaredField("descriptor");
+ } catch (NoSuchFieldException e) {
+ // For desktop java:
+ descriptorField = FileDescriptor.class.getDeclaredField("fd");
+ }
+ descriptorField.setAccessible(true);
+ descriptorField.set(result, fileDescriptor);
+ } catch (NoSuchFieldException | IllegalAccessException | IllegalArgumentException e) {
+ Log.wtf(EmulatorDebug.LOG_TAG, "Error accessing FileDescriptor#descriptor private field", e);
+ System.exit(1);
+ }
+ return result;
+ }
+
+ private static final int MSG_NEW_INPUT = 1;
+ private static final int MSG_PROCESS_EXITED = 4;
+
+ public final String mHandle = UUID.randomUUID().toString();
+
+ TerminalEmulator mEmulator;
+
+ /**
+ * A queue written to from a separate thread when the process outputs, and read by main thread to process by
+ * terminal emulator.
+ */
+ final ByteQueue mProcessToTerminalIOQueue = new ByteQueue(4096);
+ /**
+ * A queue written to from the main thread due to user interaction, and read by another thread which forwards by
+ * writing to the {@link #mTerminalFileDescriptor}.
+ */
+ final ByteQueue mTerminalToProcessIOQueue = new ByteQueue(4096);
+ /** Buffer to write translate code points into utf8 before writing to mTerminalToProcessIOQueue */
+ private final byte[] mUtf8InputBuffer = new byte[5];
+
+ /** Callback which gets notified when a session finishes or changes title. */
+ final SessionChangedCallback mChangeCallback;
+
+ /** The pid of the shell process. 0 if not started and -1 if finished running. */
+ int mShellPid;
+
+ /** The exit status of the shell process. Only valid if ${@link #mShellPid} is -1. */
+ int mShellExitStatus;
+
+ /**
+ * The file descriptor referencing the master half of a pseudo-terminal pair, resulting from calling
+ * {@link JNI#createSubprocess(String, String, String[], String[], int[], int, int)}.
+ */
+ private int mTerminalFileDescriptor;
+
+ /** Set by the application for user identification of session, not by terminal. */
+ public String mSessionName;
+
+ @SuppressLint("HandlerLeak")
+ final Handler mMainThreadHandler = new Handler() {
+ final byte[] mReceiveBuffer = new byte[4 * 1024];
+
+ @Override
+ public void handleMessage(Message msg) {
+ if (msg.what == MSG_NEW_INPUT && isRunning()) {
+ int bytesRead = mProcessToTerminalIOQueue.read(mReceiveBuffer, false);
+ if (bytesRead > 0) {
+ mEmulator.append(mReceiveBuffer, bytesRead);
+ notifyScreenUpdate();
+ }
+ } else if (msg.what == MSG_PROCESS_EXITED) {
+ int exitCode = (Integer) msg.obj;
+ cleanupResources(exitCode);
+ mChangeCallback.onSessionFinished(TerminalSession.this);
+
+ String exitDescription = "\r\n[Process completed";
+ if (exitCode > 0) {
+ // Non-zero process exit.
+ exitDescription += " (code " + exitCode + ")";
+ } else if (exitCode < 0) {
+ // Negated signal.
+ exitDescription += " (signal " + (-exitCode) + ")";
+ }
+ exitDescription += " - press Enter]";
+
+ byte[] bytesToWrite = exitDescription.getBytes(StandardCharsets.UTF_8);
+ mEmulator.append(bytesToWrite, bytesToWrite.length);
+ notifyScreenUpdate();
+ }
+ }
+ };
+
+ private final String mShellPath;
+ private final String mCwd;
+ private final String[] mArgs;
+ private final String[] mEnv;
+
+ public TerminalSession(String shellPath, String cwd, String[] args, String[] env, SessionChangedCallback changeCallback) {
+ mChangeCallback = changeCallback;
+
+ this.mShellPath = shellPath;
+ this.mCwd = cwd;
+ this.mArgs = args;
+ this.mEnv = env;
+ }
+
+ /** Inform the attached pty of the new size and reflow or initialize the emulator. */
+ public void updateSize(int columns, int rows) {
+ if (mEmulator == null) {
+ initializeEmulator(columns, rows);
+ } else {
+ JNI.setPtyWindowSize(mTerminalFileDescriptor, rows, columns);
+ mEmulator.resize(columns, rows);
+ }
+ }
+
+ /** The terminal title as set through escape sequences or null if none set. */
+ public String getTitle() {
+ return (mEmulator == null) ? null : mEmulator.getTitle();
+ }
+
+ /**
+ * Set the terminal emulator's window size and start terminal emulation.
+ *
+ * @param columns The number of columns in the terminal window.
+ * @param rows The number of rows in the terminal window.
+ */
+ public void initializeEmulator(int columns, int rows) {
+ mEmulator = new TerminalEmulator(this, columns, rows, /* transcript= */2000);
+
+ int[] processId = new int[1];
+ mTerminalFileDescriptor = JNI.createSubprocess(mShellPath, mCwd, mArgs, mEnv, processId, rows, columns);
+ mShellPid = processId[0];
+
+ final FileDescriptor terminalFileDescriptorWrapped = wrapFileDescriptor(mTerminalFileDescriptor);
+
+ new Thread("TermSessionInputReader[pid=" + mShellPid + "]") {
+ @Override
+ public void run() {
+ try (InputStream termIn = new FileInputStream(terminalFileDescriptorWrapped)) {
+ final byte[] buffer = new byte[4096];
+ while (true) {
+ int read = termIn.read(buffer);
+ if (read == -1) return;
+ if (!mProcessToTerminalIOQueue.write(buffer, 0, read)) return;
+ mMainThreadHandler.sendEmptyMessage(MSG_NEW_INPUT);
+ }
+ } catch (Exception e) {
+ // Ignore, just shutting down.
+ }
+ }
+ }.start();
+
+ new Thread("TermSessionOutputWriter[pid=" + mShellPid + "]") {
+ @Override
+ public void run() {
+ final byte[] buffer = new byte[4096];
+ try (FileOutputStream termOut = new FileOutputStream(terminalFileDescriptorWrapped)) {
+ while (true) {
+ int bytesToWrite = mTerminalToProcessIOQueue.read(buffer, true);
+ if (bytesToWrite == -1) return;
+ termOut.write(buffer, 0, bytesToWrite);
+ }
+ } catch (IOException e) {
+ // Ignore.
+ }
+ }
+ }.start();
+
+ new Thread("TermSessionWaiter[pid=" + mShellPid + "]") {
+ @Override
+ public void run() {
+ int processExitCode = JNI.waitFor(mShellPid);
+ mMainThreadHandler.sendMessage(mMainThreadHandler.obtainMessage(MSG_PROCESS_EXITED, processExitCode));
+ }
+ }.start();
+
+ }
+
+ /** Write data to the shell process. */
+ @Override
+ public void write(byte[] data, int offset, int count) {
+ if (mShellPid > 0) mTerminalToProcessIOQueue.write(data, offset, count);
+ }
+
+ /** Write the Unicode code point to the terminal encoded in UTF-8. */
+ public void writeCodePoint(boolean prependEscape, int codePoint) {
+ if (codePoint > 1114111 || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) {
+ // 1114111 (= 2**16 + 1024**2 - 1) is the highest code point, [0xD800,0xDFFF] is the surrogate range.
+ throw new IllegalArgumentException("Invalid code point: " + codePoint);
+ }
+
+ int bufferPosition = 0;
+ if (prependEscape) mUtf8InputBuffer[bufferPosition++] = 27;
+
+ if (codePoint <= /* 7 bits */0b1111111) {
+ mUtf8InputBuffer[bufferPosition++] = (byte) codePoint;
+ } else if (codePoint <= /* 11 bits */0b11111111111) {
+ /* 110xxxxx leading byte with leading 5 bits */
+ mUtf8InputBuffer[bufferPosition++] = (byte) (0b11000000 | (codePoint >> 6));
+ /* 10xxxxxx continuation byte with following 6 bits */
+ mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111));
+ } else if (codePoint <= /* 16 bits */0b1111111111111111) {
+ /* 1110xxxx leading byte with leading 4 bits */
+ mUtf8InputBuffer[bufferPosition++] = (byte) (0b11100000 | (codePoint >> 12));
+ /* 10xxxxxx continuation byte with following 6 bits */
+ mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111));
+ /* 10xxxxxx continuation byte with following 6 bits */
+ mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111));
+ } else { /* We have checked codePoint <= 1114111 above, so we have max 21 bits = 0b111111111111111111111 */
+ /* 11110xxx leading byte with leading 3 bits */
+ mUtf8InputBuffer[bufferPosition++] = (byte) (0b11110000 | (codePoint >> 18));
+ /* 10xxxxxx continuation byte with following 6 bits */
+ mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 12) & 0b111111));
+ /* 10xxxxxx continuation byte with following 6 bits */
+ mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111));
+ /* 10xxxxxx continuation byte with following 6 bits */
+ mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111));
+ }
+ write(mUtf8InputBuffer, 0, bufferPosition);
+ }
+
+ public TerminalEmulator getEmulator() {
+ return mEmulator;
+ }
+
+ /** Notify the {@link #mChangeCallback} that the screen has changed. */
+ protected void notifyScreenUpdate() {
+ mChangeCallback.onTextChanged(this);
+ }
+
+ /** Reset state for terminal emulator state. */
+ public void reset() {
+ mEmulator.reset();
+ notifyScreenUpdate();
+ }
+
+ /** Finish this terminal session by sending SIGKILL to the shell. */
+ public void finishIfRunning() {
+ if (isRunning()) {
+ try {
+ Os.kill(mShellPid, OsConstants.SIGKILL);
+ } catch (ErrnoException e) {
+ Log.w("neoterm-termux", "Failed sending SIGKILL: " + e.getMessage());
+ }
+ }
+ }
+
+ /** Cleanup resources when the process exits. */
+ void cleanupResources(int exitStatus) {
+ synchronized (this) {
+ mShellPid = -1;
+ mShellExitStatus = exitStatus;
+ }
+
+ // Stop the reader and writer threads, and close the I/O streams
+ mTerminalToProcessIOQueue.close();
+ mProcessToTerminalIOQueue.close();
+ JNI.close(mTerminalFileDescriptor);
+ }
+
+ @Override
+ public void titleChanged(String oldTitle, String newTitle) {
+ mChangeCallback.onTitleChanged(this);
+ }
+
+ public synchronized boolean isRunning() {
+ return mShellPid != -1;
+ }
+
+ /** Only valid if not {@link #isRunning()}. */
+ public synchronized int getExitStatus() {
+ return mShellExitStatus;
+ }
+
+ @Override
+ public void clipboardText(String text) {
+ mChangeCallback.onClipboardText(this, text);
+ }
+
+ @Override
+ public void onBell() {
+ mChangeCallback.onBell(this);
+ }
+
+ @Override
+ public void onColorsChanged() {
+ mChangeCallback.onColorsChanged(this);
+ }
+
+ public int getPid() {
+ return mShellPid;
+ }
+
+}
diff --git a/app/src/main/java/io/neoterm/terminal/TextStyle.java b/app/src/main/java/io/neoterm/terminal/TextStyle.java
new file mode 100755
index 0000000..2551037
--- /dev/null
+++ b/app/src/main/java/io/neoterm/terminal/TextStyle.java
@@ -0,0 +1,90 @@
+package io.neoterm.terminal;
+
+/**
+ *
+ * Encodes effects, foreground and background colors into a 64 bit long, which are stored for each cell in a terminal
+ * row in {@link TerminalRow#mStyle}.
+ *
+ *
+ * The bit layout is:
+ *
+ * - 16 flags (11 currently used).
+ * - 24 for foreground color (only 9 first bits if a color index).
+ * - 24 for background color (only 9 first bits if a color index).
+ */
+public final class TextStyle {
+
+ public final static int CHARACTER_ATTRIBUTE_BOLD = 1;
+ public final static int CHARACTER_ATTRIBUTE_ITALIC = 1 << 1;
+ public final static int CHARACTER_ATTRIBUTE_UNDERLINE = 1 << 2;
+ public final static int CHARACTER_ATTRIBUTE_BLINK = 1 << 3;
+ public final static int CHARACTER_ATTRIBUTE_INVERSE = 1 << 4;
+ public final static int CHARACTER_ATTRIBUTE_INVISIBLE = 1 << 5;
+ public final static int CHARACTER_ATTRIBUTE_STRIKETHROUGH = 1 << 6;
+ /**
+ * The selective erase control functions (DECSED and DECSEL) can only erase characters defined as erasable.
+ *
+ * This bit is set if DECSCA (Select Character Protection Attribute) has been used to define the characters that
+ * come after it as erasable from the screen.
+ *
+ */
+ public final static int CHARACTER_ATTRIBUTE_PROTECTED = 1 << 7;
+ /** Dim colors. Also known as faint or half intensity. */
+ public final static int CHARACTER_ATTRIBUTE_DIM = 1 << 8;
+ /** If true (24-bit) color is used for the cell for foreground. */
+ private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND = 1 << 9;
+ /** If true (24-bit) color is used for the cell for foreground. */
+ private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND= 1 << 10;
+
+ public final static int COLOR_INDEX_FOREGROUND = 256;
+ public final static int COLOR_INDEX_BACKGROUND = 257;
+ public final static int COLOR_INDEX_CURSOR = 258;
+
+ /** The 256 standard color entries and the three special (foreground, background and cursor) ones. */
+ public final static int NUM_INDEXED_COLORS = 259;
+
+ /** Normal foreground and background colors and no effects. */
+ final static long NORMAL = encode(COLOR_INDEX_FOREGROUND, COLOR_INDEX_BACKGROUND, 0);
+
+ static long encode(int foreColor, int backColor, int effect) {
+ long result = effect & 0b111111111;
+ if ((0xff000000 & foreColor) == 0xff000000) {
+ // 24-bit color.
+ result |= CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND | ((foreColor & 0x00ffffffL) << 40L);
+ } else {
+ // Indexed color.
+ result |= (foreColor & 0b111111111L) << 40;
+ }
+ if ((0xff000000 & backColor) == 0xff000000) {
+ // 24-bit color.
+ result |= CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND | ((backColor & 0x00ffffffL) << 16L);
+ } else {
+ // Indexed color.
+ result |= (backColor & 0b111111111L) << 16L;
+ }
+
+ return result;
+ }
+
+ public static int decodeForeColor(long style) {
+ if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND) == 0) {
+ return (int) ((style >>> 40) & 0b111111111L);
+ } else {
+ return 0xff000000 | (int) ((style >>> 40) & 0x00ffffffL);
+ }
+
+ }
+
+ public static int decodeBackColor(long style) {
+ if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND) == 0) {
+ return (int) ((style >>> 16) & 0b111111111L);
+ } else {
+ return 0xff000000 | (int) ((style >>> 16) & 0x00ffffffL);
+ }
+ }
+
+ public static int decodeEffect(long style) {
+ return (int) (style & 0b11111111111);
+ }
+
+}
diff --git a/app/src/main/java/io/neoterm/terminal/WcWidth.java b/app/src/main/java/io/neoterm/terminal/WcWidth.java
new file mode 100755
index 0000000..bf5078b
--- /dev/null
+++ b/app/src/main/java/io/neoterm/terminal/WcWidth.java
@@ -0,0 +1,458 @@
+package io.neoterm.terminal;
+
+/**
+ * Implementation of wcwidth(3) for Unicode 9.
+ *
+ * Implementation from https://github.com/jquast/wcwidth but we return 0 for unprintable characters.
+ */
+public final class WcWidth {
+
+ // From https://github.com/jquast/wcwidth/blob/master/wcwidth/table_zero.py
+ // t commit 0d7de112202cc8b2ebe9232ff4a5c954f19d561a (2016-07-02):
+ private static final int[][] ZERO_WIDTH = {
+ {0x0300, 0x036f}, // Combining Grave Accent ..Combining Latin Small Le
+ {0x0483, 0x0489}, // Combining Cyrillic Titlo..Combining Cyrillic Milli
+ {0x0591, 0x05bd}, // Hebrew Accent Etnahta ..Hebrew Point Meteg
+ {0x05bf, 0x05bf}, // Hebrew Point Rafe ..Hebrew Point Rafe
+ {0x05c1, 0x05c2}, // Hebrew Point Shin Dot ..Hebrew Point Sin Dot
+ {0x05c4, 0x05c5}, // Hebrew Mark Upper Dot ..Hebrew Mark Lower Dot
+ {0x05c7, 0x05c7}, // Hebrew Point Qamats Qata..Hebrew Point Qamats Qata
+ {0x0610, 0x061a}, // Arabic Sign Sallallahou ..Arabic Small Kasra
+ {0x064b, 0x065f}, // Arabic Fathatan ..Arabic Wavy Hamza Below
+ {0x0670, 0x0670}, // Arabic Letter Superscrip..Arabic Letter Superscrip
+ {0x06d6, 0x06dc}, // Arabic Small High Ligatu..Arabic Small High Seen
+ {0x06df, 0x06e4}, // Arabic Small High Rounde..Arabic Small High Madda
+ {0x06e7, 0x06e8}, // Arabic Small High Yeh ..Arabic Small High Noon
+ {0x06ea, 0x06ed}, // Arabic Empty Centre Low ..Arabic Small Low Meem
+ {0x0711, 0x0711}, // Syriac Letter Superscrip..Syriac Letter Superscrip
+ {0x0730, 0x074a}, // Syriac Pthaha Above ..Syriac Barrekh
+ {0x07a6, 0x07b0}, // Thaana Abafili ..Thaana Sukun
+ {0x07eb, 0x07f3}, // Nko Combining Sh||t High..Nko Combining Double Dot
+ {0x0816, 0x0819}, // Samaritan Mark In ..Samaritan Mark Dagesh
+ {0x081b, 0x0823}, // Samaritan Mark Epentheti..Samaritan Vowel Sign A
+ {0x0825, 0x0827}, // Samaritan Vowel Sign Sho..Samaritan Vowel Sign U
+ {0x0829, 0x082d}, // Samaritan Vowel Sign Lon..Samaritan Mark Nequdaa
+ {0x0859, 0x085b}, // Mandaic Affrication Mark..Mandaic Gemination Mark
+ {0x08d4, 0x08e1}, // (nil) ..
+ {0x08e3, 0x0902}, // Arabic Turned Damma Belo..Devanagari Sign Anusvara
+ {0x093a, 0x093a}, // Devanagari Vowel Sign Oe..Devanagari Vowel Sign Oe
+ {0x093c, 0x093c}, // Devanagari Sign Nukta ..Devanagari Sign Nukta
+ {0x0941, 0x0948}, // Devanagari Vowel Sign U ..Devanagari Vowel Sign Ai
+ {0x094d, 0x094d}, // Devanagari Sign Virama ..Devanagari Sign Virama
+ {0x0951, 0x0957}, // Devanagari Stress Sign U..Devanagari Vowel Sign Uu
+ {0x0962, 0x0963}, // Devanagari Vowel Sign Vo..Devanagari Vowel Sign Vo
+ {0x0981, 0x0981}, // Bengali Sign Candrabindu..Bengali Sign Candrabindu
+ {0x09bc, 0x09bc}, // Bengali Sign Nukta ..Bengali Sign Nukta
+ {0x09c1, 0x09c4}, // Bengali Vowel Sign U ..Bengali Vowel Sign Vocal
+ {0x09cd, 0x09cd}, // Bengali Sign Virama ..Bengali Sign Virama
+ {0x09e2, 0x09e3}, // Bengali Vowel Sign Vocal..Bengali Vowel Sign Vocal
+ {0x0a01, 0x0a02}, // Gurmukhi Sign Adak Bindi..Gurmukhi Sign Bindi
+ {0x0a3c, 0x0a3c}, // Gurmukhi Sign Nukta ..Gurmukhi Sign Nukta
+ {0x0a41, 0x0a42}, // Gurmukhi Vowel Sign U ..Gurmukhi Vowel Sign Uu
+ {0x0a47, 0x0a48}, // Gurmukhi Vowel Sign Ee ..Gurmukhi Vowel Sign Ai
+ {0x0a4b, 0x0a4d}, // Gurmukhi Vowel Sign Oo ..Gurmukhi Sign Virama
+ {0x0a51, 0x0a51}, // Gurmukhi Sign Udaat ..Gurmukhi Sign Udaat
+ {0x0a70, 0x0a71}, // Gurmukhi Tippi ..Gurmukhi Addak
+ {0x0a75, 0x0a75}, // Gurmukhi Sign Yakash ..Gurmukhi Sign Yakash
+ {0x0a81, 0x0a82}, // Gujarati Sign Candrabind..Gujarati Sign Anusvara
+ {0x0abc, 0x0abc}, // Gujarati Sign Nukta ..Gujarati Sign Nukta
+ {0x0ac1, 0x0ac5}, // Gujarati Vowel Sign U ..Gujarati Vowel Sign Cand
+ {0x0ac7, 0x0ac8}, // Gujarati Vowel Sign E ..Gujarati Vowel Sign Ai
+ {0x0acd, 0x0acd}, // Gujarati Sign Virama ..Gujarati Sign Virama
+ {0x0ae2, 0x0ae3}, // Gujarati Vowel Sign Voca..Gujarati Vowel Sign Voca
+ {0x0b01, 0x0b01}, // ||iya Sign Candrabindu ..||iya Sign Candrabindu
+ {0x0b3c, 0x0b3c}, // ||iya Sign Nukta ..||iya Sign Nukta
+ {0x0b3f, 0x0b3f}, // ||iya Vowel Sign I ..||iya Vowel Sign I
+ {0x0b41, 0x0b44}, // ||iya Vowel Sign U ..||iya Vowel Sign Vocalic
+ {0x0b4d, 0x0b4d}, // ||iya Sign Virama ..||iya Sign Virama
+ {0x0b56, 0x0b56}, // ||iya Ai Length Mark ..||iya Ai Length Mark
+ {0x0b62, 0x0b63}, // ||iya Vowel Sign Vocalic..||iya Vowel Sign Vocalic
+ {0x0b82, 0x0b82}, // Tamil Sign Anusvara ..Tamil Sign Anusvara
+ {0x0bc0, 0x0bc0}, // Tamil Vowel Sign Ii ..Tamil Vowel Sign Ii
+ {0x0bcd, 0x0bcd}, // Tamil Sign Virama ..Tamil Sign Virama
+ {0x0c00, 0x0c00}, // Telugu Sign Combining Ca..Telugu Sign Combining Ca
+ {0x0c3e, 0x0c40}, // Telugu Vowel Sign Aa ..Telugu Vowel Sign Ii
+ {0x0c46, 0x0c48}, // Telugu Vowel Sign E ..Telugu Vowel Sign Ai
+ {0x0c4a, 0x0c4d}, // Telugu Vowel Sign O ..Telugu Sign Virama
+ {0x0c55, 0x0c56}, // Telugu Length Mark ..Telugu Ai Length Mark
+ {0x0c62, 0x0c63}, // Telugu Vowel Sign Vocali..Telugu Vowel Sign Vocali
+ {0x0c81, 0x0c81}, // Kannada Sign Candrabindu..Kannada Sign Candrabindu
+ {0x0cbc, 0x0cbc}, // Kannada Sign Nukta ..Kannada Sign Nukta
+ {0x0cbf, 0x0cbf}, // Kannada Vowel Sign I ..Kannada Vowel Sign I
+ {0x0cc6, 0x0cc6}, // Kannada Vowel Sign E ..Kannada Vowel Sign E
+ {0x0ccc, 0x0ccd}, // Kannada Vowel Sign Au ..Kannada Sign Virama
+ {0x0ce2, 0x0ce3}, // Kannada Vowel Sign Vocal..Kannada Vowel Sign Vocal
+ {0x0d01, 0x0d01}, // Malayalam Sign Candrabin..Malayalam Sign Candrabin
+ {0x0d41, 0x0d44}, // Malayalam Vowel Sign U ..Malayalam Vowel Sign Voc
+ {0x0d4d, 0x0d4d}, // Malayalam Sign Virama ..Malayalam Sign Virama
+ {0x0d62, 0x0d63}, // Malayalam Vowel Sign Voc..Malayalam Vowel Sign Voc
+ {0x0dca, 0x0dca}, // Sinhala Sign Al-lakuna ..Sinhala Sign Al-lakuna
+ {0x0dd2, 0x0dd4}, // Sinhala Vowel Sign Ketti..Sinhala Vowel Sign Ketti
+ {0x0dd6, 0x0dd6}, // Sinhala Vowel Sign Diga ..Sinhala Vowel Sign Diga
+ {0x0e31, 0x0e31}, // Thai Character Mai Han-a..Thai Character Mai Han-a
+ {0x0e34, 0x0e3a}, // Thai Character Sara I ..Thai Character Phinthu
+ {0x0e47, 0x0e4e}, // Thai Character Maitaikhu..Thai Character Yamakkan
+ {0x0eb1, 0x0eb1}, // Lao Vowel Sign Mai Kan ..Lao Vowel Sign Mai Kan
+ {0x0eb4, 0x0eb9}, // Lao Vowel Sign I ..Lao Vowel Sign Uu
+ {0x0ebb, 0x0ebc}, // Lao Vowel Sign Mai Kon ..Lao Semivowel Sign Lo
+ {0x0ec8, 0x0ecd}, // Lao Tone Mai Ek ..Lao Niggahita
+ {0x0f18, 0x0f19}, // Tibetan Astrological Sig..Tibetan Astrological Sig
+ {0x0f35, 0x0f35}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung
+ {0x0f37, 0x0f37}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung
+ {0x0f39, 0x0f39}, // Tibetan Mark Tsa -phru ..Tibetan Mark Tsa -phru
+ {0x0f71, 0x0f7e}, // Tibetan Vowel Sign Aa ..Tibetan Sign Rjes Su Nga
+ {0x0f80, 0x0f84}, // Tibetan Vowel Sign Rever..Tibetan Mark Halanta
+ {0x0f86, 0x0f87}, // Tibetan Sign Lci Rtags ..Tibetan Sign Yang Rtags
+ {0x0f8d, 0x0f97}, // Tibetan Subjoined Sign L..Tibetan Subjoined Letter
+ {0x0f99, 0x0fbc}, // Tibetan Subjoined Letter..Tibetan Subjoined Letter
+ {0x0fc6, 0x0fc6}, // Tibetan Symbol Padma Gda..Tibetan Symbol Padma Gda
+ {0x102d, 0x1030}, // Myanmar Vowel Sign I ..Myanmar Vowel Sign Uu
+ {0x1032, 0x1037}, // Myanmar Vowel Sign Ai ..Myanmar Sign Dot Below
+ {0x1039, 0x103a}, // Myanmar Sign Virama ..Myanmar Sign Asat
+ {0x103d, 0x103e}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M
+ {0x1058, 0x1059}, // Myanmar Vowel Sign Vocal..Myanmar Vowel Sign Vocal
+ {0x105e, 0x1060}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M
+ {0x1071, 0x1074}, // Myanmar Vowel Sign Geba ..Myanmar Vowel Sign Kayah
+ {0x1082, 0x1082}, // Myanmar Consonant Sign S..Myanmar Consonant Sign S
+ {0x1085, 0x1086}, // Myanmar Vowel Sign Shan ..Myanmar Vowel Sign Shan
+ {0x108d, 0x108d}, // Myanmar Sign Shan Counci..Myanmar Sign Shan Counci
+ {0x109d, 0x109d}, // Myanmar Vowel Sign Aiton..Myanmar Vowel Sign Aiton
+ {0x135d, 0x135f}, // Ethiopic Combining Gemin..Ethiopic Combining Gemin
+ {0x1712, 0x1714}, // Tagalog Vowel Sign I ..Tagalog Sign Virama
+ {0x1732, 0x1734}, // Hanunoo Vowel Sign I ..Hanunoo Sign Pamudpod
+ {0x1752, 0x1753}, // Buhid Vowel Sign I ..Buhid Vowel Sign U
+ {0x1772, 0x1773}, // Tagbanwa Vowel Sign I ..Tagbanwa Vowel Sign U
+ {0x17b4, 0x17b5}, // Khmer Vowel Inherent Aq ..Khmer Vowel Inherent Aa
+ {0x17b7, 0x17bd}, // Khmer Vowel Sign I ..Khmer Vowel Sign Ua
+ {0x17c6, 0x17c6}, // Khmer Sign Nikahit ..Khmer Sign Nikahit
+ {0x17c9, 0x17d3}, // Khmer Sign Muusikatoan ..Khmer Sign Bathamasat
+ {0x17dd, 0x17dd}, // Khmer Sign Atthacan ..Khmer Sign Atthacan
+ {0x180b, 0x180d}, // Mongolian Free Variation..Mongolian Free Variation
+ {0x1885, 0x1886}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal
+ {0x18a9, 0x18a9}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal
+ {0x1920, 0x1922}, // Limbu Vowel Sign A ..Limbu Vowel Sign U
+ {0x1927, 0x1928}, // Limbu Vowel Sign E ..Limbu Vowel Sign O
+ {0x1932, 0x1932}, // Limbu Small Letter Anusv..Limbu Small Letter Anusv
+ {0x1939, 0x193b}, // Limbu Sign Mukphreng ..Limbu Sign Sa-i
+ {0x1a17, 0x1a18}, // Buginese Vowel Sign I ..Buginese Vowel Sign U
+ {0x1a1b, 0x1a1b}, // Buginese Vowel Sign Ae ..Buginese Vowel Sign Ae
+ {0x1a56, 0x1a56}, // Tai Tham Consonant Sign ..Tai Tham Consonant Sign
+ {0x1a58, 0x1a5e}, // Tai Tham Sign Mai Kang L..Tai Tham Consonant Sign
+ {0x1a60, 0x1a60}, // Tai Tham Sign Sakot ..Tai Tham Sign Sakot
+ {0x1a62, 0x1a62}, // Tai Tham Vowel Sign Mai ..Tai Tham Vowel Sign Mai
+ {0x1a65, 0x1a6c}, // Tai Tham Vowel Sign I ..Tai Tham Vowel Sign Oa B
+ {0x1a73, 0x1a7c}, // Tai Tham Vowel Sign Oa A..Tai Tham Sign Khuen-lue
+ {0x1a7f, 0x1a7f}, // Tai Tham Combining Crypt..Tai Tham Combining Crypt
+ {0x1ab0, 0x1abe}, // Combining Doubled Circum..Combining Parentheses Ov
+ {0x1b00, 0x1b03}, // Balinese Sign Ulu Ricem ..Balinese Sign Surang
+ {0x1b34, 0x1b34}, // Balinese Sign Rerekan ..Balinese Sign Rerekan
+ {0x1b36, 0x1b3a}, // Balinese Vowel Sign Ulu ..Balinese Vowel Sign Ra R
+ {0x1b3c, 0x1b3c}, // Balinese Vowel Sign La L..Balinese Vowel Sign La L
+ {0x1b42, 0x1b42}, // Balinese Vowel Sign Pepe..Balinese Vowel Sign Pepe
+ {0x1b6b, 0x1b73}, // Balinese Musical Symbol ..Balinese Musical Symbol
+ {0x1b80, 0x1b81}, // Sundanese Sign Panyecek ..Sundanese Sign Panglayar
+ {0x1ba2, 0x1ba5}, // Sundanese Consonant Sign..Sundanese Vowel Sign Pan
+ {0x1ba8, 0x1ba9}, // Sundanese Vowel Sign Pam..Sundanese Vowel Sign Pan
+ {0x1bab, 0x1bad}, // Sundanese Sign Virama ..Sundanese Consonant Sign
+ {0x1be6, 0x1be6}, // Batak Sign Tompi ..Batak Sign Tompi
+ {0x1be8, 0x1be9}, // Batak Vowel Sign Pakpak ..Batak Vowel Sign Ee
+ {0x1bed, 0x1bed}, // Batak Vowel Sign Karo O ..Batak Vowel Sign Karo O
+ {0x1bef, 0x1bf1}, // Batak Vowel Sign U F|| S..Batak Consonant Sign H
+ {0x1c2c, 0x1c33}, // Lepcha Vowel Sign E ..Lepcha Consonant Sign T
+ {0x1c36, 0x1c37}, // Lepcha Sign Ran ..Lepcha Sign Nukta
+ {0x1cd0, 0x1cd2}, // Vedic Tone Karshana ..Vedic Tone Prenkha
+ {0x1cd4, 0x1ce0}, // Vedic Sign Yajurvedic Mi..Vedic Tone Rigvedic Kash
+ {0x1ce2, 0x1ce8}, // Vedic Sign Visarga Svari..Vedic Sign Visarga Anuda
+ {0x1ced, 0x1ced}, // Vedic Sign Tiryak ..Vedic Sign Tiryak
+ {0x1cf4, 0x1cf4}, // Vedic Tone Candra Above ..Vedic Tone Candra Above
+ {0x1cf8, 0x1cf9}, // Vedic Tone Ring Above ..Vedic Tone Double Ring A
+ {0x1dc0, 0x1df5}, // Combining Dotted Grave A..Combining Up Tack Above
+ {0x1dfb, 0x1dff}, // (nil) ..Combining Right Arrowhea
+ {0x20d0, 0x20f0}, // Combining Left Harpoon A..Combining Asterisk Above
+ {0x2cef, 0x2cf1}, // Coptic Combining Ni Abov..Coptic Combining Spiritu
+ {0x2d7f, 0x2d7f}, // Tifinagh Consonant Joine..Tifinagh Consonant Joine
+ {0x2de0, 0x2dff}, // Combining Cyrillic Lette..Combining Cyrillic Lette
+ {0x302a, 0x302d}, // Ideographic Level Tone M..Ideographic Entering Ton
+ {0x3099, 0x309a}, // Combining Katakana-hirag..Combining Katakana-hirag
+ {0xa66f, 0xa672}, // Combining Cyrillic Vzmet..Combining Cyrillic Thous
+ {0xa674, 0xa67d}, // Combining Cyrillic Lette..Combining Cyrillic Payer
+ {0xa69e, 0xa69f}, // Combining Cyrillic Lette..Combining Cyrillic Lette
+ {0xa6f0, 0xa6f1}, // Bamum Combining Mark Koq..Bamum Combining Mark Tuk
+ {0xa802, 0xa802}, // Syloti Nagri Sign Dvisva..Syloti Nagri Sign Dvisva
+ {0xa806, 0xa806}, // Syloti Nagri Sign Hasant..Syloti Nagri Sign Hasant
+ {0xa80b, 0xa80b}, // Syloti Nagri Sign Anusva..Syloti Nagri Sign Anusva
+ {0xa825, 0xa826}, // Syloti Nagri Vowel Sign ..Syloti Nagri Vowel Sign
+ {0xa8c4, 0xa8c5}, // Saurashtra Sign Virama ..
+ {0xa8e0, 0xa8f1}, // Combining Devanagari Dig..Combining Devanagari Sig
+ {0xa926, 0xa92d}, // Kayah Li Vowel Ue ..Kayah Li Tone Calya Plop
+ {0xa947, 0xa951}, // Rejang Vowel Sign I ..Rejang Consonant Sign R
+ {0xa980, 0xa982}, // Javanese Sign Panyangga ..Javanese Sign Layar
+ {0xa9b3, 0xa9b3}, // Javanese Sign Cecak Telu..Javanese Sign Cecak Telu
+ {0xa9b6, 0xa9b9}, // Javanese Vowel Sign Wulu..Javanese Vowel Sign Suku
+ {0xa9bc, 0xa9bc}, // Javanese Vowel Sign Pepe..Javanese Vowel Sign Pepe
+ {0xa9e5, 0xa9e5}, // Myanmar Sign Shan Saw ..Myanmar Sign Shan Saw
+ {0xaa29, 0xaa2e}, // Cham Vowel Sign Aa ..Cham Vowel Sign Oe
+ {0xaa31, 0xaa32}, // Cham Vowel Sign Au ..Cham Vowel Sign Ue
+ {0xaa35, 0xaa36}, // Cham Consonant Sign La ..Cham Consonant Sign Wa
+ {0xaa43, 0xaa43}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina
+ {0xaa4c, 0xaa4c}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina
+ {0xaa7c, 0xaa7c}, // Myanmar Sign Tai Laing T..Myanmar Sign Tai Laing T
+ {0xaab0, 0xaab0}, // Tai Viet Mai Kang ..Tai Viet Mai Kang
+ {0xaab2, 0xaab4}, // Tai Viet Vowel I ..Tai Viet Vowel U
+ {0xaab7, 0xaab8}, // Tai Viet Mai Khit ..Tai Viet Vowel Ia
+ {0xaabe, 0xaabf}, // Tai Viet Vowel Am ..Tai Viet Tone Mai Ek
+ {0xaac1, 0xaac1}, // Tai Viet Tone Mai Tho ..Tai Viet Tone Mai Tho
+ {0xaaec, 0xaaed}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
+ {0xaaf6, 0xaaf6}, // Meetei Mayek Virama ..Meetei Mayek Virama
+ {0xabe5, 0xabe5}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
+ {0xabe8, 0xabe8}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign
+ {0xabed, 0xabed}, // Meetei Mayek Apun Iyek ..Meetei Mayek Apun Iyek
+ {0xfb1e, 0xfb1e}, // Hebrew Point Judeo-spani..Hebrew Point Judeo-spani
+ {0xfe00, 0xfe0f}, // Variation Select||-1 ..Variation Select||-16
+ {0xfe20, 0xfe2f}, // Combining Ligature Left ..Combining Cyrillic Titlo
+ {0x101fd, 0x101fd}, // Phaistos Disc Sign Combi..Phaistos Disc Sign Combi
+ {0x102e0, 0x102e0}, // Coptic Epact Thousands M..Coptic Epact Thousands M
+ {0x10376, 0x1037a}, // Combining Old Permic Let..Combining Old Permic Let
+ {0x10a01, 0x10a03}, // Kharoshthi Vowel Sign I ..Kharoshthi Vowel Sign Vo
+ {0x10a05, 0x10a06}, // Kharoshthi Vowel Sign E ..Kharoshthi Vowel Sign O
+ {0x10a0c, 0x10a0f}, // Kharoshthi Vowel Length ..Kharoshthi Sign Visarga
+ {0x10a38, 0x10a3a}, // Kharoshthi Sign Bar Abov..Kharoshthi Sign Dot Belo
+ {0x10a3f, 0x10a3f}, // Kharoshthi Virama ..Kharoshthi Virama
+ {0x10ae5, 0x10ae6}, // Manichaean Abbreviation ..Manichaean Abbreviation
+ {0x11001, 0x11001}, // Brahmi Sign Anusvara ..Brahmi Sign Anusvara
+ {0x11038, 0x11046}, // Brahmi Vowel Sign Aa ..Brahmi Virama
+ {0x1107f, 0x11081}, // Brahmi Number Joiner ..Kaithi Sign Anusvara
+ {0x110b3, 0x110b6}, // Kaithi Vowel Sign U ..Kaithi Vowel Sign Ai
+ {0x110b9, 0x110ba}, // Kaithi Sign Virama ..Kaithi Sign Nukta
+ {0x11100, 0x11102}, // Chakma Sign Candrabindu ..Chakma Sign Visarga
+ {0x11127, 0x1112b}, // Chakma Vowel Sign A ..Chakma Vowel Sign Uu
+ {0x1112d, 0x11134}, // Chakma Vowel Sign Ai ..Chakma Maayyaa
+ {0x11173, 0x11173}, // Mahajani Sign Nukta ..Mahajani Sign Nukta
+ {0x11180, 0x11181}, // Sharada Sign Candrabindu..Sharada Sign Anusvara
+ {0x111b6, 0x111be}, // Sharada Vowel Sign U ..Sharada Vowel Sign O
+ {0x111ca, 0x111cc}, // Sharada Sign Nukta ..Sharada Extra Sh||t Vowe
+ {0x1122f, 0x11231}, // Khojki Vowel Sign U ..Khojki Vowel Sign Ai
+ {0x11234, 0x11234}, // Khojki Sign Anusvara ..Khojki Sign Anusvara
+ {0x11236, 0x11237}, // Khojki Sign Nukta ..Khojki Sign Shadda
+ {0x1123e, 0x1123e}, // (nil) ..
+ {0x112df, 0x112df}, // Khudawadi Sign Anusvara ..Khudawadi Sign Anusvara
+ {0x112e3, 0x112ea}, // Khudawadi Vowel Sign U ..Khudawadi Sign Virama
+ {0x11300, 0x11301}, // Grantha Sign Combining A..Grantha Sign Candrabindu
+ {0x1133c, 0x1133c}, // Grantha Sign Nukta ..Grantha Sign Nukta
+ {0x11340, 0x11340}, // Grantha Vowel Sign Ii ..Grantha Vowel Sign Ii
+ {0x11366, 0x1136c}, // Combining Grantha Digit ..Combining Grantha Digit
+ {0x11370, 0x11374}, // Combining Grantha Letter..Combining Grantha Letter
+ {0x11438, 0x1143f}, // (nil) ..
+ {0x11442, 0x11444}, // (nil) ..
+ {0x11446, 0x11446}, // (nil) ..
+ {0x114b3, 0x114b8}, // Tirhuta Vowel Sign U ..Tirhuta Vowel Sign Vocal
+ {0x114ba, 0x114ba}, // Tirhuta Vowel Sign Sh||t..Tirhuta Vowel Sign Sh||t
+ {0x114bf, 0x114c0}, // Tirhuta Sign Candrabindu..Tirhuta Sign Anusvara
+ {0x114c2, 0x114c3}, // Tirhuta Sign Virama ..Tirhuta Sign Nukta
+ {0x115b2, 0x115b5}, // Siddham Vowel Sign U ..Siddham Vowel Sign Vocal
+ {0x115bc, 0x115bd}, // Siddham Sign Candrabindu..Siddham Sign Anusvara
+ {0x115bf, 0x115c0}, // Siddham Sign Virama ..Siddham Sign Nukta
+ {0x115dc, 0x115dd}, // Siddham Vowel Sign Alter..Siddham Vowel Sign Alter
+ {0x11633, 0x1163a}, // Modi Vowel Sign U ..Modi Vowel Sign Ai
+ {0x1163d, 0x1163d}, // Modi Sign Anusvara ..Modi Sign Anusvara
+ {0x1163f, 0x11640}, // Modi Sign Virama ..Modi Sign Ardhacandra
+ {0x116ab, 0x116ab}, // Takri Sign Anusvara ..Takri Sign Anusvara
+ {0x116ad, 0x116ad}, // Takri Vowel Sign Aa ..Takri Vowel Sign Aa
+ {0x116b0, 0x116b5}, // Takri Vowel Sign U ..Takri Vowel Sign Au
+ {0x116b7, 0x116b7}, // Takri Sign Nukta ..Takri Sign Nukta
+ {0x1171d, 0x1171f}, // Ahom Consonant Sign Medi..Ahom Consonant Sign Medi
+ {0x11722, 0x11725}, // Ahom Vowel Sign I ..Ahom Vowel Sign Uu
+ {0x11727, 0x1172b}, // Ahom Vowel Sign Aw ..Ahom Sign Killer
+ {0x11c30, 0x11c36}, // (nil) ..
+ {0x11c38, 0x11c3d}, // (nil) ..
+ {0x11c3f, 0x11c3f}, // (nil) ..
+ {0x11c92, 0x11ca7}, // (nil) ..
+ {0x11caa, 0x11cb0}, // (nil) ..
+ {0x11cb2, 0x11cb3}, // (nil) ..
+ {0x11cb5, 0x11cb6}, // (nil) ..
+ {0x16af0, 0x16af4}, // Bassa Vah Combining High..Bassa Vah Combining High
+ {0x16b30, 0x16b36}, // Pahawh Hmong Mark Cim Tu..Pahawh Hmong Mark Cim Ta
+ {0x16f8f, 0x16f92}, // Miao Tone Right ..Miao Tone Below
+ {0x1bc9d, 0x1bc9e}, // Duployan Thick Letter Se..Duployan Double Mark
+ {0x1d167, 0x1d169}, // Musical Symbol Combining..Musical Symbol Combining
+ {0x1d17b, 0x1d182}, // Musical Symbol Combining..Musical Symbol Combining
+ {0x1d185, 0x1d18b}, // Musical Symbol Combining..Musical Symbol Combining
+ {0x1d1aa, 0x1d1ad}, // Musical Symbol Combining..Musical Symbol Combining
+ {0x1d242, 0x1d244}, // Combining Greek Musical ..Combining Greek Musical
+ {0x1da00, 0x1da36}, // Signwriting Head Rim ..Signwriting Air Sucking
+ {0x1da3b, 0x1da6c}, // Signwriting Mouth Closed..Signwriting Excitement
+ {0x1da75, 0x1da75}, // Signwriting Upper Body T..Signwriting Upper Body T
+ {0x1da84, 0x1da84}, // Signwriting Location Hea..Signwriting Location Hea
+ {0x1da9b, 0x1da9f}, // Signwriting Fill Modifie..Signwriting Fill Modifie
+ {0x1daa1, 0x1daaf}, // Signwriting Rotation Mod..Signwriting Rotation Mod
+ {0x1e000, 0x1e006}, // (nil) ..
+ {0x1e008, 0x1e018}, // (nil) ..
+ {0x1e01b, 0x1e021}, // (nil) ..
+ {0x1e023, 0x1e024}, // (nil) ..
+ {0x1e026, 0x1e02a}, // (nil) ..
+ {0x1e8d0, 0x1e8d6}, // Mende Kikakui Combining ..Mende Kikakui Combining
+ {0x1e944, 0x1e94a}, // (nil) ..
+ {0xe0100, 0xe01ef}, // Variation Select||-17 ..Variation Select||-256
+ };
+
+ // https://github.com/jquast/wcwidth/blob/master/wcwidth/table_wide.py
+ // at commit 0d7de112202cc8b2ebe9232ff4a5c954f19d561a (2016-07-02):
+ private static final int[][] WIDE_EASTASIAN = {
+ {0x1100, 0x115f}, // Hangul Choseong Kiyeok ..Hangul Choseong Filler
+ {0x231a, 0x231b}, // Watch ..Hourglass
+ {0x2329, 0x232a}, // Left-pointing Angle Brac..Right-pointing Angle Bra
+ {0x23e9, 0x23ec}, // Black Right-pointing Dou..Black Down-pointing Doub
+ {0x23f0, 0x23f0}, // Alarm Clock ..Alarm Clock
+ {0x23f3, 0x23f3}, // Hourglass With Flowing S..Hourglass With Flowing S
+ {0x25fd, 0x25fe}, // White Medium Small Squar..Black Medium Small Squar
+ {0x2614, 0x2615}, // Umbrella With Rain Drops..Hot Beverage
+ {0x2648, 0x2653}, // Aries ..Pisces
+ {0x267f, 0x267f}, // Wheelchair Symbol ..Wheelchair Symbol
+ {0x2693, 0x2693}, // Anch|| ..Anch||
+ {0x26a1, 0x26a1}, // High Voltage Sign ..High Voltage Sign
+ {0x26aa, 0x26ab}, // Medium White Circle ..Medium Black Circle
+ {0x26bd, 0x26be}, // Soccer Ball ..Baseball
+ {0x26c4, 0x26c5}, // Snowman Without Snow ..Sun Behind Cloud
+ {0x26ce, 0x26ce}, // Ophiuchus ..Ophiuchus
+ {0x26d4, 0x26d4}, // No Entry ..No Entry
+ {0x26ea, 0x26ea}, // Church ..Church
+ {0x26f2, 0x26f3}, // Fountain ..Flag In Hole
+ {0x26f5, 0x26f5}, // Sailboat ..Sailboat
+ {0x26fa, 0x26fa}, // Tent ..Tent
+ {0x26fd, 0x26fd}, // Fuel Pump ..Fuel Pump
+ {0x2705, 0x2705}, // White Heavy Check Mark ..White Heavy Check Mark
+ {0x270a, 0x270b}, // Raised Fist ..Raised Hand
+ {0x2728, 0x2728}, // Sparkles ..Sparkles
+ {0x274c, 0x274c}, // Cross Mark ..Cross Mark
+ {0x274e, 0x274e}, // Negative Squared Cross M..Negative Squared Cross M
+ {0x2753, 0x2755}, // Black Question Mark ||na..White Exclamation Mark O
+ {0x2757, 0x2757}, // Heavy Exclamation Mark S..Heavy Exclamation Mark S
+ {0x2795, 0x2797}, // Heavy Plus Sign ..Heavy Division Sign
+ {0x27b0, 0x27b0}, // Curly Loop ..Curly Loop
+ {0x27bf, 0x27bf}, // Double Curly Loop ..Double Curly Loop
+ {0x2b1b, 0x2b1c}, // Black Large Square ..White Large Square
+ {0x2b50, 0x2b50}, // White Medium Star ..White Medium Star
+ {0x2b55, 0x2b55}, // Heavy Large Circle ..Heavy Large Circle
+ {0x2e80, 0x2e99}, // Cjk Radical Repeat ..Cjk Radical Rap
+ {0x2e9b, 0x2ef3}, // Cjk Radical Choke ..Cjk Radical C-simplified
+ {0x2f00, 0x2fd5}, // Kangxi Radical One ..Kangxi Radical Flute
+ {0x2ff0, 0x2ffb}, // Ideographic Description ..Ideographic Description
+ {0x3000, 0x303e}, // Ideographic Space ..Ideographic Variation In
+ {0x3041, 0x3096}, // Hiragana Letter Small A ..Hiragana Letter Small Ke
+ {0x3099, 0x30ff}, // Combining Katakana-hirag..Katakana Digraph Koto
+ {0x3105, 0x312d}, // Bopomofo Letter B ..Bopomofo Letter Ih
+ {0x3131, 0x318e}, // Hangul Letter Kiyeok ..Hangul Letter Araeae
+ {0x3190, 0x31ba}, // Ideographic Annotation L..Bopomofo Letter Zy
+ {0x31c0, 0x31e3}, // Cjk Stroke T ..Cjk Stroke Q
+ {0x31f0, 0x321e}, // Katakana Letter Small Ku..Parenthesized K||ean Cha
+ {0x3220, 0x3247}, // Parenthesized Ideograph ..Circled Ideograph Koto
+ {0x3250, 0x32fe}, // Partnership Sign ..Circled Katakana Wo
+ {0x3300, 0x4dbf}, // Square Apaato ..
+ {0x4e00, 0xa48c}, // Cjk Unified Ideograph-4e..Yi Syllable Yyr
+ {0xa490, 0xa4c6}, // Yi Radical Qot ..Yi Radical Ke
+ {0xa960, 0xa97c}, // Hangul Choseong Tikeut-m..Hangul Choseong Ssangyeo
+ {0xac00, 0xd7a3}, // Hangul Syllable Ga ..Hangul Syllable Hih
+ {0xf900, 0xfaff}, // Cjk Compatibility Ideogr..
+ {0xfe10, 0xfe19}, // Presentation F||m F|| Ve..Presentation F||m F|| Ve
+ {0xfe30, 0xfe52}, // Presentation F||m F|| Ve..Small Full Stop
+ {0xfe54, 0xfe66}, // Small Semicolon ..Small Equals Sign
+ {0xfe68, 0xfe6b}, // Small Reverse Solidus ..Small Commercial At
+ {0xff01, 0xff60}, // Fullwidth Exclamation Ma..Fullwidth Right White Pa
+ {0xffe0, 0xffe6}, // Fullwidth Cent Sign ..Fullwidth Won Sign
+ {0x16fe0, 0x16fe0}, // (nil) ..
+ {0x17000, 0x187ec}, // (nil) ..
+ {0x18800, 0x18af2}, // (nil) ..
+ {0x1b000, 0x1b001}, // Katakana Letter Archaic ..Hiragana Letter Archaic
+ {0x1f004, 0x1f004}, // Mahjong Tile Red Dragon ..Mahjong Tile Red Dragon
+ {0x1f0cf, 0x1f0cf}, // Playing Card Black Joker..Playing Card Black Joker
+ {0x1f18e, 0x1f18e}, // Negative Squared Ab ..Negative Squared Ab
+ {0x1f191, 0x1f19a}, // Squared Cl ..Squared Vs
+ {0x1f200, 0x1f202}, // Square Hiragana Hoka ..Squared Katakana Sa
+ {0x1f210, 0x1f23b}, // Squared Cjk Unified Ideo..
+ {0x1f240, 0x1f248}, // T||toise Shell Bracketed..T||toise Shell Bracketed
+ {0x1f250, 0x1f251}, // Circled Ideograph Advant..Circled Ideograph Accept
+ {0x1f300, 0x1f320}, // Cyclone ..Shooting Star
+ {0x1f32d, 0x1f335}, // Hot Dog ..Cactus
+ {0x1f337, 0x1f37c}, // Tulip ..Baby Bottle
+ {0x1f37e, 0x1f393}, // Bottle With Popping C||k..Graduation Cap
+ {0x1f3a0, 0x1f3ca}, // Carousel H||se ..Swimmer
+ {0x1f3cf, 0x1f3d3}, // Cricket Bat And Ball ..Table Tennis Paddle And
+ {0x1f3e0, 0x1f3f0}, // House Building ..European Castle
+ {0x1f3f4, 0x1f3f4}, // Waving Black Flag ..Waving Black Flag
+ {0x1f3f8, 0x1f43e}, // Badminton Racquet And Sh..Paw Prints
+ {0x1f440, 0x1f440}, // Eyes ..Eyes
+ {0x1f442, 0x1f4fc}, // Ear ..Videocassette
+ {0x1f4ff, 0x1f53d}, // Prayer Beads ..Down-pointing Small Red
+ {0x1f54b, 0x1f54e}, // Kaaba ..Men||ah With Nine Branch
+ {0x1f550, 0x1f567}, // Clock Face One Oclock ..Clock Face Twelve-thirty
+ {0x1f57a, 0x1f57a}, // (nil) ..
+ {0x1f595, 0x1f596}, // Reversed Hand With Middl..Raised Hand With Part Be
+ {0x1f5a4, 0x1f5a4}, // (nil) ..
+ {0x1f5fb, 0x1f64f}, // Mount Fuji ..Person With Folded Hands
+ {0x1f680, 0x1f6c5}, // Rocket ..Left Luggage
+ {0x1f6cc, 0x1f6cc}, // Sleeping Accommodation ..Sleeping Accommodation
+ {0x1f6d0, 0x1f6d2}, // Place Of W||ship ..
+ {0x1f6eb, 0x1f6ec}, // Airplane Departure ..Airplane Arriving
+ {0x1f6f4, 0x1f6f6}, // (nil) ..
+ {0x1f910, 0x1f91e}, // Zipper-mouth Face ..
+ {0x1f920, 0x1f927}, // (nil) ..
+ {0x1f930, 0x1f930}, // (nil) ..
+ {0x1f933, 0x1f93e}, // (nil) ..
+ {0x1f940, 0x1f94b}, // (nil) ..
+ {0x1f950, 0x1f95e}, // (nil) ..
+ {0x1f980, 0x1f991}, // Crab ..
+ {0x1f9c0, 0x1f9c0}, // Cheese Wedge ..Cheese Wedge
+ {0x20000, 0x2fffd}, // Cjk Unified Ideograph-20..
+ {0x30000, 0x3fffd}, // (nil) ..
+ };
+
+
+ private static boolean intable(int[][] table, int c) {
+ // First quick check f|| Latin1 etc. characters.
+ if (c < table[0][0]) return false;
+
+ // Binary search in table.
+ int bot = 0;
+ int top = table.length - 1; // (int)(size / sizeof(struct interval) - 1);
+ while (top >= bot) {
+ int mid = (bot + top) / 2;
+ if (table[mid][1] < c) {
+ bot = mid + 1;
+ } else if (table[mid][0] > c) {
+ top = mid - 1;
+ } else {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Return the terminal display width of a code point: 0, 1 || 2. */
+ public static int width(int ucs) {
+ if (ucs == 0 ||
+ ucs == 0x034F ||
+ (0x200B <= ucs && ucs <= 0x200F) ||
+ ucs == 0x2028 ||
+ ucs == 0x2029 ||
+ (0x202A <= ucs && ucs <= 0x202E) ||
+ (0x2060 <= ucs && ucs <= 0x2063)) {
+ return 0;
+ }
+
+ // C0/C1 control characters
+ // Termux change: Return 0 instead of -1.
+ if (ucs < 32 || (0x07F <= ucs && ucs < 0x0A0)) return 0;
+
+ // combining characters with zero width
+ if (intable(ZERO_WIDTH, ucs)) return 0;
+
+ return intable(WIDE_EASTASIAN, ucs) ? 2 : 1;
+ }
+
+ /** The width at an index position in a java char array. */
+ public static int width(char[] chars, int index) {
+ char c = chars[index];
+ return Character.isHighSurrogate(c) ? width(Character.toCodePoint(c, chars[index + 1])) : width(c);
+ }
+
+}
diff --git a/app/src/main/java/io/neoterm/view/ExtraKeysView.java b/app/src/main/java/io/neoterm/view/ExtraKeysView.java
new file mode 100755
index 0000000..3324e83
--- /dev/null
+++ b/app/src/main/java/io/neoterm/view/ExtraKeysView.java
@@ -0,0 +1,178 @@
+package io.neoterm.view;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.view.HapticFeedbackConstants;
+import android.view.KeyEvent;
+import android.view.View;
+import android.widget.Button;
+import android.widget.GridLayout;
+import android.widget.ToggleButton;
+
+import io.neoterm.R;
+import io.neoterm.terminal.TerminalSession;
+
+/**
+ * A view showing extra keys (such as Escape, Ctrl, Alt) not normally available on an Android soft
+ * keyboard.
+ */
+public final class ExtraKeysView extends GridLayout {
+
+ private static final int TEXT_COLOR = 0xFFFFFFFF;
+
+ public ExtraKeysView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ reload();
+ }
+
+ static void sendKey(View view, String keyName) {
+ int keyCode = 0;
+ String chars = null;
+ switch (keyName) {
+ case "ESC":
+ keyCode = KeyEvent.KEYCODE_ESCAPE;
+ break;
+ case "TAB":
+ keyCode = KeyEvent.KEYCODE_TAB;
+ break;
+ case "▲":
+ keyCode = KeyEvent.KEYCODE_DPAD_UP;
+ break;
+ case "◀":
+ keyCode = KeyEvent.KEYCODE_DPAD_LEFT;
+ break;
+ case "▶":
+ keyCode = KeyEvent.KEYCODE_DPAD_RIGHT;
+ break;
+ case "▼":
+ keyCode = KeyEvent.KEYCODE_DPAD_DOWN;
+ break;
+ case "―":
+ chars = "-";
+ break;
+ default:
+ chars = keyName;
+ }
+
+ if (keyCode > 0) {
+ view.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
+ view.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, keyCode));
+ } else {
+ TerminalView terminalView = (TerminalView) view.findViewById(R.id.terminal_view);
+ TerminalSession session = terminalView.getCurrentSession();
+ if (session != null) session.write(chars);
+ }
+ }
+
+ private ToggleButton controlButton;
+ private ToggleButton altButton;
+ private ToggleButton fnButton;
+
+ public boolean readControlButton() {
+ if (controlButton.isPressed()) return true;
+ boolean result = controlButton.isChecked();
+ if (result) {
+ controlButton.setChecked(false);
+ controlButton.setTextColor(TEXT_COLOR);
+ }
+ return result;
+ }
+
+ public boolean readAltButton() {
+ if (altButton.isPressed()) return true;
+ boolean result = altButton.isChecked();
+ if (result) {
+ altButton.setChecked(false);
+ altButton.setTextColor(TEXT_COLOR);
+ }
+ return result;
+ }
+
+ public boolean readFnButton() {
+ if (fnButton.isPressed()) return true;
+ boolean result = fnButton.isChecked();
+ if (result) {
+ fnButton.setChecked(false);
+ fnButton.setTextColor(TEXT_COLOR);
+ }
+ return result;
+ }
+
+ void reload() {
+ altButton = controlButton = null;
+ removeAllViews();
+
+ String[][] buttons = {
+ {"ESC", "CTRL", "ALT", "TAB", "―", "/", "|"}
+ };
+
+ final int rows = buttons.length;
+ final int cols = buttons[0].length;
+
+ setRowCount(rows);
+ setColumnCount(cols);
+
+ for (int row = 0; row < rows; row++) {
+ for (int col = 0; col < cols; col++) {
+ final String buttonText = buttons[row][col];
+
+ Button button;
+ switch (buttonText) {
+ case "CTRL":
+ button = controlButton = new ToggleButton(getContext(), null, android.R.attr.buttonBarButtonStyle);
+ button.setClickable(true);
+ break;
+ case "ALT":
+ button = altButton = new ToggleButton(getContext(), null, android.R.attr.buttonBarButtonStyle);
+ button.setClickable(true);
+ break;
+ case "FN":
+ button = fnButton = new ToggleButton(getContext(), null, android.R.attr.buttonBarButtonStyle);
+ button.setClickable(true);
+ break;
+ default:
+ button = new Button(getContext(), null, android.R.attr.buttonBarButtonStyle);
+ break;
+ }
+
+ button.setText(buttonText);
+ button.setTextColor(TEXT_COLOR);
+
+ final Button finalButton = button;
+ button.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ finalButton.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
+ View root = getRootView();
+ switch (buttonText) {
+ case "CTRL":
+ case "ALT":
+ case "FN":
+ ToggleButton self = (ToggleButton) finalButton;
+ self.setChecked(self.isChecked());
+ self.setTextColor(self.isChecked() ? 0xFF80DEEA : TEXT_COLOR);
+ break;
+ default:
+ sendKey(root, buttonText);
+ break;
+ }
+ }
+ });
+
+ LayoutParams param = new LayoutParams();
+ param.height = param.width = 0;
+ param.rightMargin = param.topMargin = 0;
+ param.setGravity(Gravity.LEFT);
+ float weight = "▲▼◀▶".contains(buttonText) ? 0.7f : 1.f;
+ param.columnSpec = GridLayout.spec(col, GridLayout.FILL, weight);
+ param.rowSpec = GridLayout.spec(row, GridLayout.FILL, 1.f);
+ button.setLayoutParams(param);
+
+ addView(button);
+ }
+ }
+ }
+
+}
diff --git a/app/src/main/java/io/neoterm/view/GestureAndScaleRecognizer.java b/app/src/main/java/io/neoterm/view/GestureAndScaleRecognizer.java
new file mode 100755
index 0000000..0da557b
--- /dev/null
+++ b/app/src/main/java/io/neoterm/view/GestureAndScaleRecognizer.java
@@ -0,0 +1,111 @@
+package io.neoterm.view;
+
+import android.content.Context;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import android.view.ScaleGestureDetector;
+
+/** A combination of {@link GestureDetector} and {@link ScaleGestureDetector}. */
+final class GestureAndScaleRecognizer {
+
+ public interface Listener {
+ boolean onSingleTapUp(MotionEvent e);
+
+ boolean onDoubleTap(MotionEvent e);
+
+ boolean onScroll(MotionEvent e2, float dx, float dy);
+
+ boolean onFling(MotionEvent e, float velocityX, float velocityY);
+
+ boolean onScale(float focusX, float focusY, float scale);
+
+ boolean onDown(float x, float y);
+
+ boolean onUp(MotionEvent e);
+
+ void onLongPress(MotionEvent e);
+ }
+
+ private final GestureDetector mGestureDetector;
+ private final ScaleGestureDetector mScaleDetector;
+ final Listener mListener;
+ boolean isAfterLongPress;
+
+ public GestureAndScaleRecognizer(Context context, Listener listener) {
+ mListener = listener;
+
+ mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
+ @Override
+ public boolean onScroll(MotionEvent e1, MotionEvent e2, float dx, float dy) {
+ return mListener.onScroll(e2, dx, dy);
+ }
+
+ @Override
+ public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
+ return mListener.onFling(e2, velocityX, velocityY);
+ }
+
+ @Override
+ public boolean onDown(MotionEvent e) {
+ return mListener.onDown(e.getX(), e.getY());
+ }
+
+ @Override
+ public void onLongPress(MotionEvent e) {
+ mListener.onLongPress(e);
+ isAfterLongPress = true;
+ }
+ }, null, true /* ignoreMultitouch */);
+
+ mGestureDetector.setOnDoubleTapListener(new GestureDetector.OnDoubleTapListener() {
+ @Override
+ public boolean onSingleTapConfirmed(MotionEvent e) {
+ return mListener.onSingleTapUp(e);
+ }
+
+ @Override
+ public boolean onDoubleTap(MotionEvent e) {
+ return mListener.onDoubleTap(e);
+ }
+
+ @Override
+ public boolean onDoubleTapEvent(MotionEvent e) {
+ return true;
+ }
+ });
+
+ mScaleDetector = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() {
+ @Override
+ public boolean onScaleBegin(ScaleGestureDetector detector) {
+ return true;
+ }
+
+ @Override
+ public boolean onScale(ScaleGestureDetector detector) {
+ return mListener.onScale(detector.getFocusX(), detector.getFocusY(), detector.getScaleFactor());
+ }
+ });
+ }
+
+ public void onTouchEvent(MotionEvent event) {
+ mGestureDetector.onTouchEvent(event);
+ mScaleDetector.onTouchEvent(event);
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ isAfterLongPress = false;
+ break;
+ case MotionEvent.ACTION_UP:
+ if (!isAfterLongPress) {
+ // This behaviour is desired when in e.g. vim with mouse events, where we do not
+ // want to move the cursor when lifting finger after a long press.
+ mListener.onUp(event);
+ }
+ break;
+ }
+ }
+
+ public boolean isInProgress() {
+ return mScaleDetector.isInProgress();
+ }
+
+}
diff --git a/app/src/main/java/io/neoterm/view/TerminalRenderer.java b/app/src/main/java/io/neoterm/view/TerminalRenderer.java
new file mode 100755
index 0000000..6e9af35
--- /dev/null
+++ b/app/src/main/java/io/neoterm/view/TerminalRenderer.java
@@ -0,0 +1,230 @@
+package io.neoterm.view;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.graphics.Typeface;
+
+import io.neoterm.terminal.TerminalBuffer;
+import io.neoterm.terminal.TerminalEmulator;
+import io.neoterm.terminal.TerminalRow;
+import io.neoterm.terminal.TextStyle;
+import io.neoterm.terminal.WcWidth;
+
+/**
+ * Renderer of a {@link TerminalEmulator} into a {@link Canvas}.
+ *
+ * Saves font metrics, so needs to be recreated each time the typeface or font size changes.
+ */
+final class TerminalRenderer {
+
+ final int mTextSize;
+ final Typeface mTypeface;
+ private final Paint mTextPaint = new Paint();
+
+ /** The width of a single mono spaced character obtained by {@link Paint#measureText(String)} on a single 'X'. */
+ final float mFontWidth;
+ /** The {@link Paint#getFontSpacing()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */
+ final int mFontLineSpacing;
+ /** The {@link Paint#ascent()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */
+ private final int mFontAscent;
+ /** The {@link #mFontLineSpacing} + {@link #mFontAscent}. */
+ final int mFontLineSpacingAndAscent;
+
+ private final float[] asciiMeasures = new float[127];
+
+ public TerminalRenderer(int textSize, Typeface typeface) {
+ mTextSize = textSize;
+ mTypeface = typeface;
+
+ mTextPaint.setTypeface(typeface);
+ mTextPaint.setAntiAlias(true);
+ mTextPaint.setTextSize(textSize);
+
+ mFontLineSpacing = (int) Math.ceil(mTextPaint.getFontSpacing());
+ mFontAscent = (int) Math.ceil(mTextPaint.ascent());
+ mFontLineSpacingAndAscent = mFontLineSpacing + mFontAscent;
+ mFontWidth = mTextPaint.measureText("X");
+
+ StringBuilder sb = new StringBuilder(" ");
+ for (int i = 0; i < asciiMeasures.length; i++) {
+ sb.setCharAt(0, (char) i);
+ asciiMeasures[i] = mTextPaint.measureText(sb, 0, 1);
+ }
+ }
+
+ /** Render the terminal to a canvas with at a specified row scroll, and an optional rectangular selection. */
+ public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow,
+ int selectionY1, int selectionY2, int selectionX1, int selectionX2) {
+ final boolean reverseVideo = mEmulator.isReverseVideo();
+ final int endRow = topRow + mEmulator.mRows;
+ final int columns = mEmulator.mColumns;
+ final int cursorCol = mEmulator.getCursorCol();
+ final int cursorRow = mEmulator.getCursorRow();
+ final boolean cursorVisible = mEmulator.isShowingCursor();
+ final TerminalBuffer screen = mEmulator.getScreen();
+ final int[] palette = mEmulator.mColors.mCurrentColors;
+ final int cursorShape = mEmulator.getCursorStyle();
+
+ if (reverseVideo)
+ canvas.drawColor(palette[TextStyle.COLOR_INDEX_FOREGROUND], PorterDuff.Mode.SRC);
+
+ float heightOffset = mFontLineSpacingAndAscent;
+ for (int row = topRow; row < endRow; row++) {
+ heightOffset += mFontLineSpacing;
+
+ final int cursorX = (row == cursorRow && cursorVisible) ? cursorCol : -1;
+ int selx1 = -1, selx2 = -1;
+ if (row >= selectionY1 && row <= selectionY2) {
+ if (row == selectionY1) selx1 = selectionX1;
+ selx2 = (row == selectionY2) ? selectionX2 : mEmulator.mColumns;
+ }
+
+ TerminalRow lineObject = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row));
+ final char[] line = lineObject.mText;
+ final int charsUsedInLine = lineObject.getSpaceUsed();
+
+ long lastRunStyle = 0;
+ boolean lastRunInsideCursor = false;
+ int lastRunStartColumn = -1;
+ int lastRunStartIndex = 0;
+ boolean lastRunFontWidthMismatch = false;
+ int currentCharIndex = 0;
+ float measuredWidthForRun = 0.f;
+
+ for (int column = 0; column < columns; ) {
+ final char charAtIndex = line[currentCharIndex];
+ final boolean charIsHighsurrogate = Character.isHighSurrogate(charAtIndex);
+ final int charsForCodePoint = charIsHighsurrogate ? 2 : 1;
+ final int codePoint = charIsHighsurrogate ? Character.toCodePoint(charAtIndex, line[currentCharIndex + 1]) : charAtIndex;
+ final int codePointWcWidth = WcWidth.width(codePoint);
+ final boolean insideCursor = (column >= selx1 && column <= selx2) || (cursorX == column || (codePointWcWidth == 2 && cursorX == column + 1));
+ final long style = lineObject.getStyle(column);
+
+ // Check if the measured text width for this code point is not the same as that expected by wcwidth().
+ // This could happen for some fonts which are not truly monospace, or for more exotic characters such as
+ // smileys which android font renders as wide.
+ // If this is detected, we draw this code point scaled to match what wcwidth() expects.
+ final float measuredCodePointWidth = (codePoint < asciiMeasures.length) ? asciiMeasures[codePoint] : mTextPaint.measureText(line,
+ currentCharIndex, charsForCodePoint);
+ final boolean fontWidthMismatch = Math.abs(measuredCodePointWidth / mFontWidth - codePointWcWidth) > 0.01;
+
+ if (style != lastRunStyle || insideCursor != lastRunInsideCursor || fontWidthMismatch || lastRunFontWidthMismatch) {
+ if (column == 0) {
+ // Skip first column as there is nothing to draw, just record the current style.
+ } else {
+ final int columnWidthSinceLastRun = column - lastRunStartColumn;
+ final int charsSinceLastRun = currentCharIndex - lastRunStartIndex;
+ int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0;
+ drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun,
+ lastRunStartIndex, charsSinceLastRun, measuredWidthForRun,
+ cursorColor, cursorShape, lastRunStyle, reverseVideo);
+ }
+ measuredWidthForRun = 0.f;
+ lastRunStyle = style;
+ lastRunInsideCursor = insideCursor;
+ lastRunStartColumn = column;
+ lastRunStartIndex = currentCharIndex;
+ lastRunFontWidthMismatch = fontWidthMismatch;
+ }
+ measuredWidthForRun += measuredCodePointWidth;
+ column += codePointWcWidth;
+ currentCharIndex += charsForCodePoint;
+ while (currentCharIndex < charsUsedInLine && WcWidth.width(line, currentCharIndex) <= 0) {
+ // Eat combining chars so that they are treated as part of the last non-combining code point,
+ // instead of e.g. being considered inside the cursor in the next run.
+ currentCharIndex += Character.isHighSurrogate(line[currentCharIndex]) ? 2 : 1;
+ }
+ }
+
+ final int columnWidthSinceLastRun = columns - lastRunStartColumn;
+ final int charsSinceLastRun = currentCharIndex - lastRunStartIndex;
+ int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0;
+ drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, lastRunStartIndex, charsSinceLastRun,
+ measuredWidthForRun, cursorColor, cursorShape, lastRunStyle, reverseVideo);
+ }
+ }
+
+ private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int startColumn, int runWidthColumns,
+ int startCharIndex, int runWidthChars, float mes, int cursor, int cursorStyle,
+ long textStyle, boolean reverseVideo) {
+ int foreColor = TextStyle.decodeForeColor(textStyle);
+ final int effect = TextStyle.decodeEffect(textStyle);
+ int backColor = TextStyle.decodeBackColor(textStyle);
+ final boolean bold = (effect & (TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0;
+ final boolean underline = (effect & TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE) != 0;
+ final boolean italic = (effect & TextStyle.CHARACTER_ATTRIBUTE_ITALIC) != 0;
+ final boolean strikeThrough = (effect & TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0;
+ final boolean dim = (effect & TextStyle.CHARACTER_ATTRIBUTE_DIM) != 0;
+
+ if ((foreColor & 0xff000000) != 0xff000000) {
+ // Let bold have bright colors if applicable (one of the first 8):
+ if (bold && foreColor >= 0 && foreColor < 8) foreColor += 8;
+ foreColor = palette[foreColor];
+ }
+
+ if ((backColor & 0xff000000) != 0xff000000) {
+ backColor = palette[backColor];
+ }
+
+ // Reverse video here if _one and only one_ of the reverse flags are set:
+ final boolean reverseVideoHere = reverseVideo ^ (effect & (TextStyle.CHARACTER_ATTRIBUTE_INVERSE)) != 0;
+ if (reverseVideoHere) {
+ int tmp = foreColor;
+ foreColor = backColor;
+ backColor = tmp;
+ }
+
+ float left = startColumn * mFontWidth;
+ float right = left + runWidthColumns * mFontWidth;
+
+ mes = mes / mFontWidth;
+ boolean savedMatrix = false;
+ if (Math.abs(mes - runWidthColumns) > 0.01) {
+ canvas.save();
+ canvas.scale(runWidthColumns / mes, 1.f);
+ left *= mes / runWidthColumns;
+ right *= mes / runWidthColumns;
+ savedMatrix = true;
+ }
+
+ if (backColor != palette[TextStyle.COLOR_INDEX_BACKGROUND]) {
+ // Only draw non-default background.
+ mTextPaint.setColor(backColor);
+ canvas.drawRect(left, y - mFontLineSpacingAndAscent + mFontAscent, right, y, mTextPaint);
+ }
+
+ if (cursor != 0) {
+ mTextPaint.setColor(cursor);
+ float cursorHeight = mFontLineSpacingAndAscent - mFontAscent;
+ if (cursorStyle == TerminalEmulator.CURSOR_STYLE_UNDERLINE) cursorHeight /= 4.;
+ else if (cursorStyle == TerminalEmulator.CURSOR_STYLE_BAR) right -= ((right - left) * 3) / 4.;
+ canvas.drawRect(left, y - cursorHeight, right, y, mTextPaint);
+ }
+
+ if ((effect & TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) == 0) {
+ if (dim) {
+ int red = (0xFF & (foreColor >> 16));
+ int green = (0xFF & (foreColor >> 8));
+ int blue = (0xFF & foreColor);
+ // Dim color handling used by libvte which in turn took it from xterm
+ // (https://bug735245.bugzilla-attachments.gnome.org/attachment.cgi?id=284267):
+ red = red * 2 / 3;
+ green = green * 2 / 3;
+ blue = blue * 2 / 3;
+ foreColor = 0xFF000000 + (red << 16) + (green << 8) + blue;
+ }
+
+ mTextPaint.setFakeBoldText(bold);
+ mTextPaint.setUnderlineText(underline);
+ mTextPaint.setTextSkewX(italic ? -0.35f : 0.f);
+ mTextPaint.setStrikeThruText(strikeThrough);
+ mTextPaint.setColor(foreColor);
+
+ // The text alignment is the default Paint.Align.LEFT.
+ canvas.drawText(text, startCharIndex, runWidthChars, left, y - mFontLineSpacingAndAscent, mTextPaint);
+ }
+
+ if (savedMatrix) canvas.restore();
+ }
+}
diff --git a/app/src/main/java/io/neoterm/view/TerminalView.java b/app/src/main/java/io/neoterm/view/TerminalView.java
new file mode 100755
index 0000000..a7b7530
--- /dev/null
+++ b/app/src/main/java/io/neoterm/view/TerminalView.java
@@ -0,0 +1,919 @@
+package io.neoterm.view;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.Typeface;
+import android.graphics.drawable.BitmapDrawable;
+import android.os.Build;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.ActionMode;
+import android.view.HapticFeedbackConstants;
+import android.view.InputDevice;
+import android.view.KeyCharacterMap;
+import android.view.KeyEvent;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.inputmethod.BaseInputConnection;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.widget.Scroller;
+
+import io.neoterm.R;
+import io.neoterm.terminal.EmulatorDebug;
+import io.neoterm.terminal.KeyHandler;
+import io.neoterm.terminal.TerminalBuffer;
+import io.neoterm.terminal.TerminalEmulator;
+import io.neoterm.terminal.TerminalSession;
+
+/** View displaying and interacting with a {@link TerminalSession}. */
+public final class TerminalView extends View {
+
+ /** Log view key and IME events. */
+ private static final boolean LOG_KEY_EVENTS = false;
+
+ /** The currently displayed terminal session, whose emulator is {@link #mEmulator}. */
+ TerminalSession mTermSession;
+ /** Our terminal emulator whose session is {@link #mTermSession}. */
+ TerminalEmulator mEmulator;
+
+ TerminalRenderer mRenderer;
+
+ TerminalViewClient mClient;
+
+ /** The top row of text to display. Ranges from -activeTranscriptRows to 0. */
+ int mTopRow;
+
+ boolean mIsSelectingText = false, mIsDraggingLeftSelection, mInitialTextSelection;
+ int mSelX1 = -1, mSelX2 = -1, mSelY1 = -1, mSelY2 = -1;
+ float mSelectionDownX, mSelectionDownY;
+ private ActionMode mActionMode;
+ private BitmapDrawable mLeftSelectionHandle, mRightSelectionHandle;
+
+ float mScaleFactor = 1.f;
+ final GestureAndScaleRecognizer mGestureRecognizer;
+
+ /** Keep track of where mouse touch event started which we report as mouse scroll. */
+ private int mMouseScrollStartX = -1, mMouseScrollStartY = -1;
+ /** Keep track of the time when a touch event leading to sending mouse scroll events started. */
+ private long mMouseStartDownTime = -1;
+
+ final Scroller mScroller;
+
+ /** What was left in from scrolling movement. */
+ float mScrollRemainder;
+
+ /** If non-zero, this is the last unicode code point received if that was a combining character. */
+ int mCombiningAccent;
+ int mTextSize;
+
+ public TerminalView(Context context, AttributeSet attributeSet) { // NO_UCD (unused code)
+ super(context, attributeSet);
+ mGestureRecognizer = new GestureAndScaleRecognizer(context, new GestureAndScaleRecognizer.Listener() {
+
+ boolean scrolledWithFinger;
+
+ @Override
+ public boolean onUp(MotionEvent e) {
+ mScrollRemainder = 0.0f;
+ if (mEmulator != null && mEmulator.isMouseTrackingActive() && !mIsSelectingText && !scrolledWithFinger) {
+ // Quick event processing when mouse tracking is active - do not wait for check of double tapping
+ // for zooming.
+ sendMouseEventCode(e, TerminalEmulator.MOUSE_LEFT_BUTTON, true);
+ sendMouseEventCode(e, TerminalEmulator.MOUSE_LEFT_BUTTON, false);
+ return true;
+ }
+ scrolledWithFinger = false;
+ return false;
+ }
+
+ @Override
+ public boolean onSingleTapUp(MotionEvent e) {
+ if (mEmulator == null) return true;
+ if (mIsSelectingText) {
+ toggleSelectingText(null);
+ return true;
+ }
+ requestFocus();
+ if (!mEmulator.isMouseTrackingActive()) {
+ if (!e.isFromSource(InputDevice.SOURCE_MOUSE)) {
+ mClient.onSingleTapUp(e);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onScroll(MotionEvent e, float distanceX, float distanceY) {
+ if (mEmulator == null || mIsSelectingText) return true;
+ if (mEmulator.isMouseTrackingActive() && e.isFromSource(InputDevice.SOURCE_MOUSE)) {
+ // If moving with mouse pointer while pressing button, report that instead of scroll.
+ // This means that we never report moving with button press-events for touch input,
+ // since we cannot just start sending these events without a starting press event,
+ // which we do not do for touch input, only mouse in onTouchEvent().
+ sendMouseEventCode(e, TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED, true);
+ } else {
+ scrolledWithFinger = true;
+ distanceY += mScrollRemainder;
+ int deltaRows = (int) (distanceY / mRenderer.mFontLineSpacing);
+ mScrollRemainder = distanceY - deltaRows * mRenderer.mFontLineSpacing;
+ doScroll(e, deltaRows);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onScale(float focusX, float focusY, float scale) {
+ if (mEmulator == null || mIsSelectingText) return true;
+ mScaleFactor *= scale;
+ mScaleFactor = mClient.onScale(mScaleFactor);
+ return true;
+ }
+
+ @Override
+ public boolean onFling(final MotionEvent e2, float velocityX, float velocityY) {
+ if (mEmulator == null || mIsSelectingText) return true;
+ // Do not start scrolling until last fling has been taken care of:
+ if (!mScroller.isFinished()) return true;
+
+ final boolean mouseTrackingAtStartOfFling = mEmulator.isMouseTrackingActive();
+ float SCALE = 0.25f;
+ if (mouseTrackingAtStartOfFling) {
+ mScroller.fling(0, 0, 0, -(int) (velocityY * SCALE), 0, 0, -mEmulator.mRows / 2, mEmulator.mRows / 2);
+ } else {
+ mScroller.fling(0, mTopRow, 0, -(int) (velocityY * SCALE), 0, 0, -mEmulator.getScreen().getActiveTranscriptRows(), 0);
+ }
+
+ post(new Runnable() {
+ private int mLastY = 0;
+
+ @Override
+ public void run() {
+ if (mouseTrackingAtStartOfFling != mEmulator.isMouseTrackingActive()) {
+ mScroller.abortAnimation();
+ return;
+ }
+ if (mScroller.isFinished()) return;
+ boolean more = mScroller.computeScrollOffset();
+ int newY = mScroller.getCurrY();
+ int diff = mouseTrackingAtStartOfFling ? (newY - mLastY) : (newY - mTopRow);
+ doScroll(e2, diff);
+ mLastY = newY;
+ if (more) post(this);
+ }
+ });
+
+ return true;
+ }
+
+ @Override
+ public boolean onDown(float x, float y) {
+ return false;
+ }
+
+ @Override
+ public boolean onDoubleTap(MotionEvent e) {
+ // Do not treat is as a single confirmed tap - it may be followed by zoom.
+ return false;
+ }
+
+ @Override
+ public void onLongPress(MotionEvent e) {
+ if (mGestureRecognizer.isInProgress()) return;
+ if (mClient.onLongPress(e)) return;
+ if (!mIsSelectingText) {
+ performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
+ toggleSelectingText(e);
+ }
+ }
+ });
+ mScroller = new Scroller(context);
+ }
+
+ /**
+ * @param onKeyListener Listener for all kinds of key events, both hardware and IME (which makes it different from that
+ * available with {@link View#setOnKeyListener(OnKeyListener)}.
+ */
+ public void setOnKeyListener(TerminalViewClient onKeyListener) {
+ this.mClient = onKeyListener;
+ }
+
+ /**
+ * Attach a {@link TerminalSession} to this view.
+ *
+ * @param session The {@link TerminalSession} this view will be displaying.
+ */
+ public boolean attachSession(TerminalSession session) {
+ if (session == mTermSession) return false;
+ mTopRow = 0;
+
+ mTermSession = session;
+ mEmulator = null;
+ mCombiningAccent = 0;
+
+ updateSize();
+
+ // Wait with enabling the scrollbar until we have a terminal to get scroll position from.
+ setVerticalScrollBarEnabled(true);
+
+ return true;
+ }
+
+ @Override
+ public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
+ // Using InputType.NULL is the most correct input type and avoids issues with other hacks.
+ //
+ // Previous keyboard issues:
+ // https://github.com/termux/termux-packages/issues/25
+ // https://github.com/termux/termux-app/issues/87.
+ // https://github.com/termux/termux-app/issues/126.
+ // https://github.com/termux/termux-app/issues/137 (japanese chars and TYPE_NULL).
+ outAttrs.inputType = InputType.TYPE_NULL;
+
+ // Note that IME_ACTION_NONE cannot be used as that makes it impossible to input newlines using the on-screen
+ // keyboard on Android TV (see https://github.com/termux/termux-app/issues/221).
+ outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN;
+
+ return new BaseInputConnection(this, true) {
+
+ @Override
+ public boolean finishComposingText() {
+ if (LOG_KEY_EVENTS) Log.i(EmulatorDebug.LOG_TAG, "IME: finishComposingText()");
+ super.finishComposingText();
+
+ sendTextToTerminal(getEditable());
+ getEditable().clear();
+ return true;
+ }
+
+ @Override
+ public boolean commitText(CharSequence text, int newCursorPosition) {
+ if (LOG_KEY_EVENTS) {
+ Log.i(EmulatorDebug.LOG_TAG, "IME: commitText(\"" + text + "\", " + newCursorPosition + ")");
+ }
+ super.commitText(text, newCursorPosition);
+
+ if (mEmulator == null) return true;
+
+ Editable content = getEditable();
+ sendTextToTerminal(content);
+ content.clear();
+ return true;
+ }
+
+ @Override
+ public boolean deleteSurroundingText(int leftLength, int rightLength) {
+ if (LOG_KEY_EVENTS) {
+ Log.i(EmulatorDebug.LOG_TAG, "IME: deleteSurroundingText(" + leftLength + ", " + rightLength + ")");
+ }
+ // The stock Samsung keyboard with 'Auto check spelling' enabled sends leftLength > 1.
+ KeyEvent deleteKey = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL);
+ for (int i = 0; i < leftLength; i++) sendKeyEvent(deleteKey);
+ return super.deleteSurroundingText(leftLength, rightLength);
+ }
+
+ void sendTextToTerminal(CharSequence text) {
+ final int textLengthInChars = text.length();
+ for (int i = 0; i < textLengthInChars; i++) {
+ char firstChar = text.charAt(i);
+ int codePoint;
+ if (Character.isHighSurrogate(firstChar)) {
+ if (++i < textLengthInChars) {
+ codePoint = Character.toCodePoint(firstChar, text.charAt(i));
+ } else {
+ // At end of string, with no low surrogate following the high:
+ codePoint = TerminalEmulator.UNICODE_REPLACEMENT_CHAR;
+ }
+ } else {
+ codePoint = firstChar;
+ }
+
+ boolean ctrlHeld = false;
+ if (codePoint <= 31 && codePoint != 27) {
+ if (codePoint == '\n') {
+ // The AOSP keyboard and descendants seems to send \n as text when the enter key is pressed,
+ // instead of a key event like most other keyboard apps. A terminal expects \r for the enter
+ // key (although when icrnl is enabled this doesn't make a difference - run 'stty -icrnl' to
+ // check the behaviour).
+ codePoint = '\r';
+ }
+
+ // E.g. penti keyboard for ctrl input.
+ ctrlHeld = true;
+ switch (codePoint) {
+ case 31:
+ codePoint = '_';
+ break;
+ case 30:
+ codePoint = '^';
+ break;
+ case 29:
+ codePoint = ']';
+ break;
+ case 28:
+ codePoint = '\\';
+ break;
+ default:
+ codePoint += 96;
+ break;
+ }
+ }
+
+ inputCodePoint(codePoint, ctrlHeld, false);
+ }
+ }
+
+ };
+ }
+
+ @Override
+ protected int computeVerticalScrollRange() {
+ return mEmulator == null ? 1 : mEmulator.getScreen().getActiveRows();
+ }
+
+ @Override
+ protected int computeVerticalScrollExtent() {
+ return mEmulator == null ? 1 : mEmulator.mRows;
+ }
+
+ @Override
+ protected int computeVerticalScrollOffset() {
+ return mEmulator == null ? 1 : mEmulator.getScreen().getActiveRows() + mTopRow - mEmulator.mRows;
+ }
+
+ public void onScreenUpdated() {
+ if (mEmulator == null) return;
+
+ boolean skipScrolling = false;
+ if (mIsSelectingText) {
+ // Do not scroll when selecting text.
+ int rowsInHistory = mEmulator.getScreen().getActiveTranscriptRows();
+ int rowShift = mEmulator.getScrollCounter();
+ if (-mTopRow + rowShift > rowsInHistory) {
+ // .. unless we're hitting the end of history transcript, in which
+ // case we abort text selection and scroll to end.
+ toggleSelectingText(null);
+ } else {
+ skipScrolling = true;
+ mTopRow -= rowShift;
+ mSelY1 -= rowShift;
+ mSelY2 -= rowShift;
+ }
+ }
+
+ if (!skipScrolling && mTopRow != 0) {
+ // Scroll down if not already there.
+ if (mTopRow < -3) {
+ // Awaken scroll bars only if scrolling a noticeable amount
+ // - we do not want visible scroll bars during normal typing
+ // of one row at a time.
+ awakenScrollBars();
+ }
+ mTopRow = 0;
+ }
+
+ mEmulator.clearScrollCounter();
+
+ invalidate();
+ }
+
+ public int getTextSize() {
+ return mTextSize;
+ }
+
+ /**
+ * Sets the text size, which in turn sets the number of rows and columns.
+ *
+ * @param textSize the new font size, in density-independent pixels.
+ */
+ public void setTextSize(int textSize) {
+ this.mTextSize = textSize;
+ mRenderer = new TerminalRenderer(textSize, mRenderer == null ? Typeface.MONOSPACE : mRenderer.mTypeface);
+ updateSize();
+ }
+
+ public void setTypeface(Typeface newTypeface) {
+ mRenderer = new TerminalRenderer(mRenderer.mTextSize, newTypeface);
+ updateSize();
+ invalidate();
+ }
+
+ @Override
+ public boolean onCheckIsTextEditor() {
+ return true;
+ }
+
+ @Override
+ public boolean isOpaque() {
+ return true;
+ }
+
+ /** Send a single mouse event code to the terminal. */
+ void sendMouseEventCode(MotionEvent e, int button, boolean pressed) {
+ int x = (int) (e.getX() / mRenderer.mFontWidth) + 1;
+ int y = (int) ((e.getY() - mRenderer.mFontLineSpacingAndAscent) / mRenderer.mFontLineSpacing) + 1;
+ if (pressed && (button == TerminalEmulator.MOUSE_WHEELDOWN_BUTTON || button == TerminalEmulator.MOUSE_WHEELUP_BUTTON)) {
+ if (mMouseStartDownTime == e.getDownTime()) {
+ x = mMouseScrollStartX;
+ y = mMouseScrollStartY;
+ } else {
+ mMouseStartDownTime = e.getDownTime();
+ mMouseScrollStartX = x;
+ mMouseScrollStartY = y;
+ }
+ }
+ mEmulator.sendMouseEvent(button, x, y, pressed);
+ }
+
+ /** Perform a scroll, either from dragging the screen or by scrolling a mouse wheel. */
+ void doScroll(MotionEvent event, int rowsDown) {
+ boolean up = rowsDown < 0;
+ int amount = Math.abs(rowsDown);
+ for (int i = 0; i < amount; i++) {
+ if (mEmulator.isMouseTrackingActive()) {
+ sendMouseEventCode(event, up ? TerminalEmulator.MOUSE_WHEELUP_BUTTON : TerminalEmulator.MOUSE_WHEELDOWN_BUTTON, true);
+ } else if (mEmulator.isAlternateBufferActive()) {
+ // Send up and down key events for scrolling, which is what some terminals do to make scroll work in
+ // e.g. less, which shifts to the alt screen without mouse handling.
+ handleKeyCode(up ? KeyEvent.KEYCODE_DPAD_UP : KeyEvent.KEYCODE_DPAD_DOWN, 0);
+ } else {
+ mTopRow = Math.min(0, Math.max(-(mEmulator.getScreen().getActiveTranscriptRows()), mTopRow + (up ? -1 : 1)));
+ if (!awakenScrollBars()) invalidate();
+ }
+ }
+ }
+
+ /** Overriding {@link View#onGenericMotionEvent(MotionEvent)}. */
+ @Override
+ public boolean onGenericMotionEvent(MotionEvent event) {
+ if (mEmulator != null && event.isFromSource(InputDevice.SOURCE_MOUSE) && event.getAction() == MotionEvent.ACTION_SCROLL) {
+ // Handle mouse wheel scrolling.
+ boolean up = event.getAxisValue(MotionEvent.AXIS_VSCROLL) > 0.0f;
+ doScroll(event, up ? -3 : 3);
+ return true;
+ }
+ return false;
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ @Override
+ @TargetApi(23)
+ public boolean onTouchEvent(MotionEvent ev) {
+ if (mEmulator == null) return true;
+ final int action = ev.getAction();
+
+ if (mIsSelectingText) {
+ int cy = (int) (ev.getY() / mRenderer.mFontLineSpacing) + mTopRow;
+ int cx = (int) (ev.getX() / mRenderer.mFontWidth);
+
+ switch (action) {
+ case MotionEvent.ACTION_UP:
+ mInitialTextSelection = false;
+ break;
+ case MotionEvent.ACTION_DOWN:
+ int distanceFromSel1 = Math.abs(cx - mSelX1) + Math.abs(cy - mSelY1);
+ int distanceFromSel2 = Math.abs(cx - mSelX2) + Math.abs(cy - mSelY2);
+ mIsDraggingLeftSelection = distanceFromSel1 <= distanceFromSel2;
+ mSelectionDownX = ev.getX();
+ mSelectionDownY = ev.getY();
+ break;
+ case MotionEvent.ACTION_MOVE:
+ if (mInitialTextSelection) break;
+ float deltaX = ev.getX() - mSelectionDownX;
+ float deltaY = ev.getY() - mSelectionDownY;
+ int deltaCols = (int) Math.ceil(deltaX / mRenderer.mFontWidth);
+ int deltaRows = (int) Math.ceil(deltaY / mRenderer.mFontLineSpacing);
+ mSelectionDownX += deltaCols * mRenderer.mFontWidth;
+ mSelectionDownY += deltaRows * mRenderer.mFontLineSpacing;
+ if (mIsDraggingLeftSelection) {
+ mSelX1 += deltaCols;
+ mSelY1 += deltaRows;
+ } else {
+ mSelX2 += deltaCols;
+ mSelY2 += deltaRows;
+ }
+
+ mSelX1 = Math.min(mEmulator.mColumns, Math.max(0, mSelX1));
+ mSelX2 = Math.min(mEmulator.mColumns, Math.max(0, mSelX2));
+
+ if (mSelY1 == mSelY2 && mSelX1 > mSelX2 || mSelY1 > mSelY2) {
+ // Switch handles.
+ mIsDraggingLeftSelection = !mIsDraggingLeftSelection;
+ int tmpX1 = mSelX1, tmpY1 = mSelY1;
+ mSelX1 = mSelX2;
+ mSelY1 = mSelY2;
+ mSelX2 = tmpX1;
+ mSelY2 = tmpY1;
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
+ mActionMode.invalidateContentRect();
+ invalidate();
+ break;
+ default:
+ break;
+ }
+ mGestureRecognizer.onTouchEvent(ev);
+ return true;
+ } else if (ev.isFromSource(InputDevice.SOURCE_MOUSE)) {
+ if (ev.isButtonPressed(MotionEvent.BUTTON_SECONDARY)) {
+ if (action == MotionEvent.ACTION_DOWN) showContextMenu();
+ return true;
+ } else if (ev.isButtonPressed(MotionEvent.BUTTON_TERTIARY)) {
+ ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE);
+ ClipData clipData = clipboard.getPrimaryClip();
+ if (clipData != null) {
+ CharSequence paste = clipData.getItemAt(0).coerceToText(getContext());
+ if (!TextUtils.isEmpty(paste)) mEmulator.paste(paste.toString());
+ }
+ } else if (mEmulator.isMouseTrackingActive()) { // BUTTON_PRIMARY.
+ switch (ev.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_UP:
+ sendMouseEventCode(ev, TerminalEmulator.MOUSE_LEFT_BUTTON, ev.getAction() == MotionEvent.ACTION_DOWN);
+ break;
+ case MotionEvent.ACTION_MOVE:
+ sendMouseEventCode(ev, TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED, true);
+ break;
+ }
+ return true;
+ }
+ }
+
+ mGestureRecognizer.onTouchEvent(ev);
+ return true;
+ }
+
+ @Override
+ public boolean onKeyPreIme(int keyCode, KeyEvent event) {
+ if (LOG_KEY_EVENTS)
+ Log.i(EmulatorDebug.LOG_TAG, "onKeyPreIme(keyCode=" + keyCode + ", event=" + event + ")");
+ if (keyCode == KeyEvent.KEYCODE_BACK) {
+ if (mIsSelectingText) {
+ toggleSelectingText(null);
+ return true;
+ } else if (mClient.shouldBackButtonBeMappedToEscape()) {
+ // Intercept back button to treat it as escape:
+ switch (event.getAction()) {
+ case KeyEvent.ACTION_DOWN:
+ return onKeyDown(keyCode, event);
+ case KeyEvent.ACTION_UP:
+ return onKeyUp(keyCode, event);
+ }
+ }
+ }
+ return super.onKeyPreIme(keyCode, event);
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent event) {
+ if (LOG_KEY_EVENTS)
+ Log.i(EmulatorDebug.LOG_TAG, "onKeyDown(keyCode=" + keyCode + ", isSystem()=" + event.isSystem() + ", event=" + event + ")");
+ if (mEmulator == null) return true;
+
+ if (mClient.onKeyDown(keyCode, event, mTermSession)) {
+ invalidate();
+ return true;
+ } else if (event.isSystem() && (!mClient.shouldBackButtonBeMappedToEscape() || keyCode != KeyEvent.KEYCODE_BACK)) {
+ return super.onKeyDown(keyCode, event);
+ } else if (event.getAction() == KeyEvent.ACTION_MULTIPLE && keyCode == KeyEvent.KEYCODE_UNKNOWN) {
+ mTermSession.write(event.getCharacters());
+ return true;
+ }
+
+ final int metaState = event.getMetaState();
+ final boolean controlDownFromEvent = event.isCtrlPressed();
+ final boolean leftAltDownFromEvent = (metaState & KeyEvent.META_ALT_LEFT_ON) != 0;
+ final boolean rightAltDownFromEvent = (metaState & KeyEvent.META_ALT_RIGHT_ON) != 0;
+
+ int keyMod = 0;
+ if (controlDownFromEvent) keyMod |= KeyHandler.KEYMOD_CTRL;
+ if (event.isAltPressed()) keyMod |= KeyHandler.KEYMOD_ALT;
+ if (event.isShiftPressed()) keyMod |= KeyHandler.KEYMOD_SHIFT;
+ if (handleKeyCode(keyCode, keyMod)) {
+ if (LOG_KEY_EVENTS) Log.i(EmulatorDebug.LOG_TAG, "handleKeyCode() took key event");
+ return true;
+ }
+
+ // Clear Ctrl since we handle that ourselves:
+ int bitsToClear = KeyEvent.META_CTRL_MASK;
+ if (rightAltDownFromEvent) {
+ // Let right Alt/Alt Gr be used to compose characters.
+ } else {
+ // Use left alt to send to terminal (e.g. Left Alt+B to jump back a word), so remove:
+ bitsToClear |= KeyEvent.META_ALT_ON | KeyEvent.META_ALT_LEFT_ON;
+ }
+ int effectiveMetaState = event.getMetaState() & ~bitsToClear;
+
+ int result = event.getUnicodeChar(effectiveMetaState);
+ if (LOG_KEY_EVENTS)
+ Log.i(EmulatorDebug.LOG_TAG, "KeyEvent#getUnicodeChar(" + effectiveMetaState + ") returned: " + result);
+ if (result == 0) {
+ return true;
+ }
+
+ int oldCombiningAccent = mCombiningAccent;
+ if ((result & KeyCharacterMap.COMBINING_ACCENT) != 0) {
+ // If entered combining accent previously, write it out:
+ if (mCombiningAccent != 0)
+ inputCodePoint(mCombiningAccent, controlDownFromEvent, leftAltDownFromEvent);
+ mCombiningAccent = result & KeyCharacterMap.COMBINING_ACCENT_MASK;
+ } else {
+ if (mCombiningAccent != 0) {
+ int combinedChar = KeyCharacterMap.getDeadChar(mCombiningAccent, result);
+ if (combinedChar > 0) result = combinedChar;
+ mCombiningAccent = 0;
+ }
+ inputCodePoint(result, controlDownFromEvent, leftAltDownFromEvent);
+ }
+
+ if (mCombiningAccent != oldCombiningAccent) invalidate();
+
+ return true;
+ }
+
+ void inputCodePoint(int codePoint, boolean controlDownFromEvent, boolean leftAltDownFromEvent) {
+ if (LOG_KEY_EVENTS) {
+ Log.i(EmulatorDebug.LOG_TAG, "inputCodePoint(codePoint=" + codePoint + ", controlDownFromEvent=" + controlDownFromEvent + ", leftAltDownFromEvent="
+ + leftAltDownFromEvent + ")");
+ }
+
+ final boolean controlDown = controlDownFromEvent || mClient.readControlKey();
+ final boolean altDown = leftAltDownFromEvent || mClient.readAltKey();
+
+ if (mClient.onCodePoint(codePoint, controlDown, mTermSession)) return;
+
+ if (controlDown) {
+ if (codePoint >= 'a' && codePoint <= 'z') {
+ codePoint = codePoint - 'a' + 1;
+ } else if (codePoint >= 'A' && codePoint <= 'Z') {
+ codePoint = codePoint - 'A' + 1;
+ } else if (codePoint == ' ' || codePoint == '2') {
+ codePoint = 0;
+ } else if (codePoint == '[' || codePoint == '3') {
+ codePoint = 27; // ^[ (Esc)
+ } else if (codePoint == '\\' || codePoint == '4') {
+ codePoint = 28;
+ } else if (codePoint == ']' || codePoint == '5') {
+ codePoint = 29;
+ } else if (codePoint == '^' || codePoint == '6') {
+ codePoint = 30; // control-^
+ } else if (codePoint == '_' || codePoint == '7' || codePoint == '/') {
+ // "Ctrl-/ sends 0x1f which is equivalent of Ctrl-_ since the days of VT102"
+ // - http://apple.stackexchange.com/questions/24261/how-do-i-send-c-that-is-control-slash-to-the-terminal
+ codePoint = 31;
+ } else if (codePoint == '8') {
+ codePoint = 127; // DEL
+ }
+ }
+
+ if (codePoint > -1) {
+ // Work around bluetooth keyboards sending funny unicode characters instead
+ // of the more normal ones from ASCII that terminal programs expect - the
+ // desire to input the original characters should be low.
+ switch (codePoint) {
+ case 0x02DC: // SMALL TILDE.
+ codePoint = 0x007E; // TILDE (~).
+ break;
+ case 0x02CB: // MODIFIER LETTER GRAVE ACCENT.
+ codePoint = 0x0060; // GRAVE ACCENT (`).
+ break;
+ case 0x02C6: // MODIFIER LETTER CIRCUMFLEX ACCENT.
+ codePoint = 0x005E; // CIRCUMFLEX ACCENT (^).
+ break;
+ }
+
+ // If left alt, send escape before the code point to make e.g. Alt+B and Alt+F work in readline:
+ mTermSession.writeCodePoint(altDown, codePoint);
+ }
+ }
+
+ /** Input the specified keyCode if applicable and return if the input was consumed. */
+ public boolean handleKeyCode(int keyCode, int keyMod) {
+ TerminalEmulator term = mTermSession.getEmulator();
+ String code = KeyHandler.getCode(keyCode, keyMod, term.isCursorKeysApplicationMode(), term.isKeypadApplicationMode());
+ if (code == null) return false;
+ mTermSession.write(code);
+ return true;
+ }
+
+ /**
+ * Called when a key is released in the view.
+ *
+ * @param keyCode The keycode of the key which was released.
+ * @param event A {@link KeyEvent} describing the event.
+ * @return Whether the event was handled.
+ */
+ @Override
+ public boolean onKeyUp(int keyCode, KeyEvent event) {
+ if (LOG_KEY_EVENTS)
+ Log.i(EmulatorDebug.LOG_TAG, "onKeyUp(keyCode=" + keyCode + ", event=" + event + ")");
+ if (mEmulator == null) return true;
+
+ if (mClient.onKeyUp(keyCode, event)) {
+ invalidate();
+ return true;
+ } else if (event.isSystem()) {
+ // Let system key events through.
+ return super.onKeyUp(keyCode, event);
+ }
+
+ return true;
+ }
+
+ /**
+ * This is called during layout when the size of this view has changed. If you were just added to the view
+ * hierarchy, you're called with the old values of 0.
+ */
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ updateSize();
+ }
+
+ /** Check if the terminal size in rows and columns should be updated. */
+ public void updateSize() {
+ int viewWidth = getWidth();
+ int viewHeight = getHeight();
+ if (viewWidth == 0 || viewHeight == 0 || mTermSession == null) return;
+
+ // Set to 80 and 24 if you want to enable vttest.
+ int newColumns = Math.max(4, (int) (viewWidth / mRenderer.mFontWidth));
+ int newRows = Math.max(4, (viewHeight - mRenderer.mFontLineSpacingAndAscent) / mRenderer.mFontLineSpacing);
+
+ if (mEmulator == null || (newColumns != mEmulator.mColumns || newRows != mEmulator.mRows)) {
+ mTermSession.updateSize(newColumns, newRows);
+ mEmulator = mTermSession.getEmulator();
+
+ mTopRow = 0;
+ scrollTo(0, 0);
+ invalidate();
+ }
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ if (mEmulator == null) {
+ canvas.drawColor(0XFF000000);
+ } else {
+ mRenderer.render(mEmulator, canvas, mTopRow, mSelY1, mSelY2, mSelX1, mSelX2);
+
+ if (mIsSelectingText) {
+ final int gripHandleWidth = mLeftSelectionHandle.getIntrinsicWidth();
+ final int gripHandleMargin = gripHandleWidth / 4; // See the png.
+
+ int right = Math.round((mSelX1) * mRenderer.mFontWidth) + gripHandleMargin;
+ int top = (mSelY1 + 1 - mTopRow) * mRenderer.mFontLineSpacing + mRenderer.mFontLineSpacingAndAscent;
+ mLeftSelectionHandle.setBounds(right - gripHandleWidth, top, right, top + mLeftSelectionHandle.getIntrinsicHeight());
+ mLeftSelectionHandle.draw(canvas);
+
+ int left = Math.round((mSelX2 + 1) * mRenderer.mFontWidth) - gripHandleMargin;
+ top = (mSelY2 + 1 - mTopRow) * mRenderer.mFontLineSpacing + mRenderer.mFontLineSpacingAndAscent;
+ mRightSelectionHandle.setBounds(left, top, left + gripHandleWidth, top + mRightSelectionHandle.getIntrinsicHeight());
+ mRightSelectionHandle.draw(canvas);
+ }
+ }
+ }
+
+ /** Toggle text selection mode in the view. */
+ @TargetApi(23)
+ public void toggleSelectingText(MotionEvent ev) {
+ mIsSelectingText = !mIsSelectingText;
+ mClient.copyModeChanged(mIsSelectingText);
+
+ if (mIsSelectingText) {
+ if (mLeftSelectionHandle == null) {
+ mLeftSelectionHandle = (BitmapDrawable) getContext().getDrawable(R.drawable.text_select_handle_left_material);
+ mRightSelectionHandle = (BitmapDrawable) getContext().getDrawable(R.drawable.text_select_handle_right_material);
+ }
+
+ int cx = (int) (ev.getX() / mRenderer.mFontWidth);
+ final boolean eventFromMouse = ev.isFromSource(InputDevice.SOURCE_MOUSE);
+ // Offset for finger:
+ final int SELECT_TEXT_OFFSET_Y = eventFromMouse ? 0 : -40;
+ int cy = (int) ((ev.getY() + SELECT_TEXT_OFFSET_Y) / mRenderer.mFontLineSpacing) + mTopRow;
+
+ mSelX1 = mSelX2 = cx;
+ mSelY1 = mSelY2 = cy;
+
+ TerminalBuffer screen = mEmulator.getScreen();
+ if (!" ".equals(screen.getSelectedText(mSelX1, mSelY1, mSelX1, mSelY1))) {
+ // Selecting something other than whitespace. Expand to word.
+ while (mSelX1 > 0 && !"".equals(screen.getSelectedText(mSelX1 - 1, mSelY1, mSelX1 - 1, mSelY1))) {
+ mSelX1--;
+ }
+ while (mSelX2 < mEmulator.mColumns - 1 && !"".equals(screen.getSelectedText(mSelX2 + 1, mSelY1, mSelX2 + 1, mSelY1))) {
+ mSelX2++;
+ }
+ }
+
+ mInitialTextSelection = true;
+ mIsDraggingLeftSelection = true;
+ mSelectionDownX = ev.getX();
+ mSelectionDownY = ev.getY();
+
+ final ActionMode.Callback callback = new ActionMode.Callback() {
+ @Override
+ public boolean onCreateActionMode(ActionMode mode, Menu menu) {
+ int show = MenuItem.SHOW_AS_ACTION_ALWAYS | MenuItem.SHOW_AS_ACTION_WITH_TEXT;
+
+ ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE);
+ menu.add(Menu.NONE, 1, Menu.NONE, R.string.copy_text).setShowAsAction(show);
+ menu.add(Menu.NONE, 2, Menu.NONE, R.string.paste_text).setEnabled(clipboard.hasPrimaryClip()).setShowAsAction(show);
+ menu.add(Menu.NONE, 3, Menu.NONE, R.string.text_selection_more);
+ return true;
+ }
+
+ @Override
+ public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
+ return false;
+ }
+
+ @Override
+ public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
+ switch (item.getItemId()) {
+ case 1:
+ String selectedText = mEmulator.getSelectedText(mSelX1, mSelY1, mSelX2, mSelY2).trim();
+ mTermSession.clipboardText(selectedText);
+ break;
+ case 2:
+ ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE);
+ ClipData clipData = clipboard.getPrimaryClip();
+ if (clipData != null) {
+ CharSequence paste = clipData.getItemAt(0).coerceToText(getContext());
+ if (!TextUtils.isEmpty(paste)) mEmulator.paste(paste.toString());
+ }
+ break;
+ case 3:
+ showContextMenu();
+ break;
+ }
+ toggleSelectingText(null);
+ return true;
+ }
+
+ @Override
+ public void onDestroyActionMode(ActionMode mode) {
+ }
+
+ };
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ mActionMode = startActionMode(new ActionMode.Callback2() {
+ @Override
+ public boolean onCreateActionMode(ActionMode mode, Menu menu) {
+ return callback.onCreateActionMode(mode, menu);
+ }
+
+ @Override
+ public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
+ return false;
+ }
+
+ @Override
+ public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
+ return callback.onActionItemClicked(mode, item);
+ }
+
+ @Override
+ public void onDestroyActionMode(ActionMode mode) {
+ // Ignore.
+ }
+
+ @Override
+ public void onGetContentRect(ActionMode mode, View view, Rect outRect) {
+ int x1 = Math.round(mSelX1 * mRenderer.mFontWidth);
+ int x2 = Math.round(mSelX2 * mRenderer.mFontWidth);
+ int y1 = Math.round((mSelY1 - mTopRow) * mRenderer.mFontLineSpacing);
+ int y2 = Math.round((mSelY2 + 1 - mTopRow) * mRenderer.mFontLineSpacing);
+ outRect.set(Math.min(x1, x2), y1, Math.max(x1, x2), y2);
+ }
+ }, ActionMode.TYPE_FLOATING);
+ } else {
+ mActionMode = startActionMode(callback);
+ }
+
+
+ invalidate();
+ } else {
+ mActionMode.finish();
+ mSelX1 = mSelY1 = mSelX2 = mSelY2 = -1;
+ invalidate();
+ }
+ }
+
+ public TerminalSession getCurrentSession() {
+ return mTermSession;
+ }
+
+}
diff --git a/app/src/main/java/io/neoterm/view/TerminalViewClient.java b/app/src/main/java/io/neoterm/view/TerminalViewClient.java
new file mode 100755
index 0000000..aaaca93
--- /dev/null
+++ b/app/src/main/java/io/neoterm/view/TerminalViewClient.java
@@ -0,0 +1,42 @@
+package io.neoterm.view;
+
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.ScaleGestureDetector;
+
+import io.neoterm.terminal.TerminalSession;
+
+/**
+ * Input and scale listener which may be set on a {@link TerminalView} through
+ * {@link TerminalView#setOnKeyListener(TerminalViewClient)}.
+ *
+ */
+public interface TerminalViewClient {
+
+ /**
+ * Callback function on scale events according to {@link ScaleGestureDetector#getScaleFactor()}.
+ */
+ float onScale(float scale);
+
+ /**
+ * On a single tap on the terminal if terminal mouse reporting not enabled.
+ */
+ void onSingleTapUp(MotionEvent e);
+
+ boolean shouldBackButtonBeMappedToEscape();
+
+ void copyModeChanged(boolean copyMode);
+
+ boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession session);
+
+ boolean onKeyUp(int keyCode, KeyEvent e);
+
+ boolean readControlKey();
+
+ boolean readAltKey();
+
+ boolean onCodePoint(int codePoint, boolean ctrlDown, TerminalSession session);
+
+ boolean onLongPress(MotionEvent event);
+
+}
diff --git a/app/src/main/res/drawable-hdpi/ic_add_box_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_add_box_white_24dp.png
new file mode 100755
index 0000000..50814b4
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_add_box_white_24dp.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_add_box_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_add_box_white_24dp.png
new file mode 100755
index 0000000..de65263
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_add_box_white_24dp.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_add_box_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_add_box_white_24dp.png
new file mode 100755
index 0000000..97c73d8
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_add_box_white_24dp.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_add_box_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_add_box_white_24dp.png
new file mode 100755
index 0000000..4d054c9
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_add_box_white_24dp.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/text_select_handle_left_mtrl_alpha.png b/app/src/main/res/drawable-xxhdpi/text_select_handle_left_mtrl_alpha.png
new file mode 100755
index 0000000..39818db
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/text_select_handle_left_mtrl_alpha.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/text_select_handle_right_mtrl_alpha.png b/app/src/main/res/drawable-xxhdpi/text_select_handle_right_mtrl_alpha.png
new file mode 100755
index 0000000..260e090
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/text_select_handle_right_mtrl_alpha.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_add_box_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_add_box_white_24dp.png
new file mode 100755
index 0000000..1f300d6
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_add_box_white_24dp.png differ
diff --git a/app/src/main/res/drawable/text_select_handle_left_material.xml b/app/src/main/res/drawable/text_select_handle_left_material.xml
new file mode 100755
index 0000000..89733d5
--- /dev/null
+++ b/app/src/main/res/drawable/text_select_handle_left_material.xml
@@ -0,0 +1,4 @@
+
+
diff --git a/app/src/main/res/drawable/text_select_handle_right_material.xml b/app/src/main/res/drawable/text_select_handle_right_material.xml
new file mode 100755
index 0000000..21a2cb8
--- /dev/null
+++ b/app/src/main/res/drawable/text_select_handle_right_material.xml
@@ -0,0 +1,4 @@
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..22e8d3d
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/tab_main.xml b/app/src/main/res/layout/tab_main.xml
new file mode 100644
index 0000000..f48ee1c
--- /dev/null
+++ b/app/src/main/res/layout/tab_main.xml
@@ -0,0 +1,14 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/tab_switcher.xml b/app/src/main/res/menu/tab_switcher.xml
new file mode 100755
index 0000000..b2d491c
--- /dev/null
+++ b/app/src/main/res/menu/tab_switcher.xml
@@ -0,0 +1,30 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..5507303
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..8fab6a3
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..6bc7fcd
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..1eecc0e
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..ec87dce
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..05ca079
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..6f67f21
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..8bac0f2
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..0327e13
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..bacd3e7
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..3ab3e9c
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+ #3F51B5
+ #303F9F
+ #FF4081
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..a1c3efc
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,10 @@
+
+ Neo Term
+ Copy
+ Paste
+ More
+
+ Toggle switcher
+ New tab
+ Clear all tabs
+
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..9785e0c
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/app/src/test/java/io/neoterm/ExampleUnitTest.kt b/app/src/test/java/io/neoterm/ExampleUnitTest.kt
new file mode 100644
index 0000000..9d79b11
--- /dev/null
+++ b/app/src/test/java/io/neoterm/ExampleUnitTest.kt
@@ -0,0 +1,18 @@
+package io.neoterm
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * @see [Testing documentation](http://d.android.com/tools/testing)
+ */
+class ExampleUnitTest {
+ @Test
+ @Throws(Exception::class)
+ fun addition_isCorrect() {
+ assertEquals(4, (2 + 2).toLong())
+ }
+}
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 0000000..d51b410
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,28 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+
+buildscript {
+ ext.kotlin_version = '1.1.2-4'
+ repositories {
+ maven { url 'http://maven.aliyun.com/nexus/content/groups/public/' }
+ jcenter()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:3.0.0-alpha1'
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+
+ // NOTE: Do not place your application dependencies here; they belong
+ // in the individual module build.gradle files
+ }
+}
+
+allprojects {
+ repositories {
+ jcenter()
+ maven { url 'http://maven.aliyun.com/nexus/content/groups/public/' }
+ mavenCentral()
+ }
+}
+
+task clean(type: Delete) {
+ delete rootProject.buildDir
+}
diff --git a/chrome-tabs/.gitignore b/chrome-tabs/.gitignore
new file mode 100755
index 0000000..796b96d
--- /dev/null
+++ b/chrome-tabs/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/chrome-tabs/build.gradle b/chrome-tabs/build.gradle
new file mode 100755
index 0000000..1b391f0
--- /dev/null
+++ b/chrome-tabs/build.gradle
@@ -0,0 +1,25 @@
+apply plugin: 'com.android.library'
+
+android {
+ compileSdkVersion 25
+ buildToolsVersion "25.0.2"
+
+ defaultConfig {
+ minSdkVersion 21
+ targetSdkVersion 25
+ versionCode 1
+ versionName "1.0"
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ }
+ }
+}
+
+dependencies {
+ compile 'com.github.michael-rapp:android-util:1.15.0'
+ compile 'com.android.support:support-annotations:25.3.0'
+ compile 'com.android.support:appcompat-v7:25.3.0'
+ testCompile 'junit:junit:4.12'
+}
diff --git a/chrome-tabs/gradle.properties b/chrome-tabs/gradle.properties
new file mode 100755
index 0000000..f639a40
--- /dev/null
+++ b/chrome-tabs/gradle.properties
@@ -0,0 +1,3 @@
+POM_NAME=ChromeLikeTabSwitcher
+POM_ARTIFACT_ID=chrome-like-tab-switcher
+POM_PACKAGING=aar
\ No newline at end of file
diff --git a/chrome-tabs/src/main/AndroidManifest.xml b/chrome-tabs/src/main/AndroidManifest.xml
new file mode 100755
index 0000000..b458ce8
--- /dev/null
+++ b/chrome-tabs/src/main/AndroidManifest.xml
@@ -0,0 +1,16 @@
+
+
+
+
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Animation.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Animation.java
new file mode 100755
index 0000000..a77ffe8
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Animation.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.view.animation.Interpolator;
+
+import static de.mrapp.android.util.Condition.ensureAtLeast;
+
+/**
+ * An animation, which can be used to add or remove tabs to/from a {@link TabSwitcher}.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public abstract class Animation {
+
+ /**
+ * An abstract base class for all builders, which allow to configure and create instances of the
+ * class {@link Animation}.
+ *
+ * @param
+ * The type of the animations, which are created by the builder
+ * @param
+ * The type of the builder
+ */
+ protected static abstract class Builder {
+
+ /**
+ * The duration of the animations, which are created by the builder.
+ */
+ protected long duration;
+
+ /**
+ * The interpolator, which is used by the animations, which are created by the builder.
+ */
+ protected Interpolator interpolator;
+
+ /**
+ * Returns a reference to the builder.
+ *
+ * @return A reference to the builder, casted to the generic type BuilderType. The reference
+ * may not be null
+ */
+ @NonNull
+ @SuppressWarnings("unchecked")
+ protected final BuilderType self() {
+ return (BuilderType) this;
+ }
+
+ /**
+ * Creates a new builder, which allows to configure and create instances of the class {@link
+ * Animation}.
+ */
+ public Builder() {
+ setDuration(-1);
+ setInterpolator(null);
+ }
+
+ /**
+ * Creates and returns the animation.
+ *
+ * @return The animation, which has been created, as an instance of the generic type
+ * AnimationType. The animation may not be null
+ */
+ @NonNull
+ public abstract AnimationType create();
+
+ /**
+ * Sets the duration of the animations, which are created by the builder.
+ *
+ * @param duration
+ * The duration, which should be set, in milliseconds as a {@link Long} value or -1,
+ * if the default duration should be used
+ * @return The builder, this method has be called upon, as an instance of the generic type
+ * BuilderType. The builder may not be null
+ */
+ @NonNull
+ public final BuilderType setDuration(final long duration) {
+ ensureAtLeast(duration, -1, "The duration must be at least -1");
+ this.duration = duration;
+ return self();
+ }
+
+ /**
+ * Sets the interpolator, which should be used by the animations, which are created by the
+ * builder.
+ *
+ * @param interpolator
+ * The interpolator, which should be set, as an instance of the type {@link
+ * Interpolator} or null, if the default interpolator should be used
+ * @return The builder, this method has be called upon, as an instance of the generic type
+ * BuilderType. The builder may not be null
+ */
+ @NonNull
+ public final BuilderType setInterpolator(@Nullable final Interpolator interpolator) {
+ this.interpolator = interpolator;
+ return self();
+ }
+
+ }
+
+ /**
+ * The duration of the animation in milliseconds.
+ */
+ private final long duration;
+
+ /**
+ * The interpolator, which is used by the animation.
+ */
+ private final Interpolator interpolator;
+
+ /**
+ * Creates a new animation.
+ *
+ * @param duration
+ * The duration of the animation in milliseconds as a {@link Long} value or -1, if the
+ * default duration should be used
+ * @param interpolator
+ * The interpolator, which should be used by the animation, as an instance of the type
+ * {@link Interpolator} or null, if the default interpolator should be used
+ */
+ protected Animation(final long duration, @Nullable final Interpolator interpolator) {
+ ensureAtLeast(duration, -1, "The duration must be at least -1");
+ this.duration = duration;
+ this.interpolator = interpolator;
+ }
+
+ /**
+ * Returns the duration of the animation.
+ *
+ * @return The duration of the animation in milliseconds as a {@link Long} value or -1, if the
+ * default duration is used
+ */
+ public final long getDuration() {
+ return duration;
+ }
+
+ /**
+ * Returns the interpolator, which is used by the animation.
+ *
+ * @return The interpolator, which is used by the animation, as an instance of the type {@link
+ * Interpolator} or null, if the default interpolator is used
+ */
+ @Nullable
+ public final Interpolator getInterpolator() {
+ return interpolator;
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Layout.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Layout.java
new file mode 100755
index 0000000..fd3d341
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Layout.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher;
+
+/**
+ * Contains all possible layouts of a {@link TabSwitcher}.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public enum Layout {
+
+ /**
+ * The layout, which is used on smartphones and phablet devices, when in portrait mode.
+ */
+ PHONE_PORTRAIT,
+
+ /**
+ * The layout, which is used on smartphones and phablet devices, when in landscape mode.
+ */
+ PHONE_LANDSCAPE,
+
+ /**
+ * The layout, which is used on tablets.
+ */
+ TABLET
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/LayoutPolicy.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/LayoutPolicy.java
new file mode 100755
index 0000000..b413623
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/LayoutPolicy.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher;
+
+/**
+ * Contains all possible layout policies of a {@link TabSwitcher}.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public enum LayoutPolicy {
+
+ /**
+ * If the layout should automatically adapted, depending on whether the device is a smartphone
+ * or tablet.
+ */
+ AUTO(0),
+
+ /**
+ * If the smartphone layout should be used, regardless of the device.
+ */
+ PHONE(1),
+
+ /**
+ * If the tablet layout should be used, regardless of the device.
+ */
+ TABLET(2);
+
+ /**
+ * The value of the layout policy.
+ */
+ private int value;
+
+ /**
+ * Creates a new layout policy.
+ *
+ * @param value
+ * The value of the layout policy as an {@link Integer} value
+ */
+ LayoutPolicy(final int value) {
+ this.value = value;
+ }
+
+ /**
+ * Returns the value of the layout policy.
+ *
+ * @return The value of the layout policy as an {@link Integer} value
+ */
+ public final int getValue() {
+ return value;
+ }
+
+ /**
+ * Returns the layout policy, which corresponds to a specific value.
+ *
+ * @param value
+ * The value of the layout policy, which should be returned, as an {@link Integer}
+ * value
+ * @return The layout policy, which corresponds to the given value, as a value of the enum
+ * {@link LayoutPolicy}
+ */
+ public static LayoutPolicy fromValue(final int value) {
+ for (LayoutPolicy layoutPolicy : values()) {
+ if (layoutPolicy.getValue() == value) {
+ return layoutPolicy;
+ }
+ }
+
+ throw new IllegalArgumentException("Invalid enum value: " + value);
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/PeekAnimation.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/PeekAnimation.java
new file mode 100755
index 0000000..ff155cf
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/PeekAnimation.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.view.animation.Interpolator;
+
+/**
+ * A peek animation, which animates the size of a tab starting at a specific position in order to
+ * show the tab for a short time at the end of a {@link TabSwitcher}. Peek animations can be used to
+ * add tabs while the tab switcher is not shown and when using the smartphone layout. They are meant
+ * to be used when adding a tab without selecting it and enable the user to peek at the added tab
+ * for a short moment.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class PeekAnimation extends Animation {
+
+ /**
+ * A builder, which allows to configure and create instances of the class {@link
+ * PeekAnimation}.
+ */
+ public static class Builder extends Animation.Builder {
+
+ /**
+ * The horizontal position, the animations, which are created by the builder, start at.
+ */
+ private float x;
+
+ /**
+ * The vertical position, the animations, which are created by the builder, start at.
+ */
+ private float y;
+
+ /**
+ * Creates a new builder, which allows to configure and create instances of the class {@link
+ * PeekAnimation}.
+ */
+ public Builder() {
+ setX(0);
+ setY(0);
+ }
+
+ /**
+ * Sets the horizontal position, the animations, which are created by the builder, should
+ * start at.
+ *
+ * @param x
+ * The horizontal position, which should be set, in pixels as a {@link Float} value
+ * @return The builder, this method has be called upon, as an instance of the generic type
+ * BuilderType. The builder may not be null
+ */
+ @NonNull
+ public final Builder setX(final float x) {
+ this.x = x;
+ return self();
+ }
+
+ /**
+ * Sets the vertical position, the animations, which are created by the builder, should
+ * start at.
+ *
+ * @param y
+ * The vertical position, which should be set, in pixels as a {@link Float} value
+ * @return The builder, this method has be called upon, as an instance of the generic type
+ * BuilderType. The builder may not be null
+ */
+ @NonNull
+ public final Builder setY(final float y) {
+ this.y = y;
+ return self();
+ }
+
+ @NonNull
+ @Override
+ public final PeekAnimation create() {
+ return new PeekAnimation(duration, interpolator, x, y);
+ }
+
+ }
+
+ /**
+ * The horizontal position, the animation starts at.
+ */
+ private final float x;
+
+ /**
+ * The vertical position, the animation starts at.
+ */
+ private final float y;
+
+ /**
+ * Creates a new reveal animation.
+ *
+ * @param x
+ * The horizontal position, the animation should start at, in pixels as a {@link Float}
+ * value
+ * @param y
+ * The vertical position, the animation should start at, in pixels as a {@link Float}
+ * value
+ */
+ private PeekAnimation(final long duration, @Nullable final Interpolator interpolator,
+ final float x, final float y) {
+ super(duration, interpolator);
+ this.x = x;
+ this.y = y;
+ }
+
+ /**
+ * Returns the horizontal position, the animation starts at.
+ *
+ * @return The horizontal position, the animation starts at, in pixels as a {@link Float} value
+ */
+ public final float getX() {
+ return x;
+ }
+
+ /**
+ * Returns the vertical position, the animation starts at.
+ *
+ * @return The vertical position, the animation starts at, in pixels as a {@link Float} value
+ */
+ public final float getY() {
+ return y;
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/RevealAnimation.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/RevealAnimation.java
new file mode 100755
index 0000000..3ab7668
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/RevealAnimation.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.view.animation.Interpolator;
+
+/**
+ * A reveal animation, which animates the size of a tab starting at a specific position. Reveal
+ * animations can be used to add tabs to a {@link TabSwitcher} when using the smartphone layout.
+ * Tabs, which have been added by using a reveal animation, are selected automatically.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class RevealAnimation extends Animation {
+
+ /**
+ * A builder, which allows to configure and create instances of the class {@link
+ * RevealAnimation}.
+ */
+ public static class Builder extends Animation.Builder {
+
+ /**
+ * The horizontal position, the animations, which are created by the builder, start at.
+ */
+ private float x;
+
+ /**
+ * The vertical position, the animations, which are created by the builder, start at.
+ */
+ private float y;
+
+ /**
+ * Creates a new builder, which allows to configure and create instances of the class {@link
+ * RevealAnimation}.
+ */
+ public Builder() {
+ setX(0);
+ setY(0);
+ }
+
+ /**
+ * Sets the horizontal position, the animations, which are created by the builder, should
+ * start at.
+ *
+ * @param x
+ * The horizontal position, which should be set, in pixels as a {@link Float} value
+ * @return The builder, this method has be called upon, as an instance of the generic type
+ * BuilderType. The builder may not be null
+ */
+ @NonNull
+ public final Builder setX(final float x) {
+ this.x = x;
+ return self();
+ }
+
+ /**
+ * Sets the vertical position, the animations, which are created by the builder, should
+ * start at.
+ *
+ * @param y
+ * The vertical position, which should be set, in pixels as a {@link Float} value
+ * @return The builder, this method has be called upon, as an instance of the generic type
+ * BuilderType. The builder may not be null
+ */
+ @NonNull
+ public final Builder setY(final float y) {
+ this.y = y;
+ return self();
+ }
+
+ @NonNull
+ @Override
+ public final RevealAnimation create() {
+ return new RevealAnimation(duration, interpolator, x, y);
+ }
+
+ }
+
+ /**
+ * The horizontal position, the animation starts at.
+ */
+ private final float x;
+
+ /**
+ * The vertical position, the animation starts at.
+ */
+ private final float y;
+
+ /**
+ * Creates a new reveal animation.
+ *
+ * @param x
+ * The horizontal position, the animation should start at, in pixels as a {@link Float}
+ * value
+ * @param y
+ * The vertical position, the animation should start at, in pixels as a {@link Float}
+ * value
+ */
+ private RevealAnimation(final long duration, @Nullable final Interpolator interpolator,
+ final float x, final float y) {
+ super(duration, interpolator);
+ this.x = x;
+ this.y = y;
+ }
+
+ /**
+ * Returns the horizontal position, the animation starts at.
+ *
+ * @return The horizontal position, the animation starts at, in pixels as a {@link Float} value
+ */
+ public final float getX() {
+ return x;
+ }
+
+ /**
+ * Returns the vertical position, the animation starts at.
+ *
+ * @return The vertical position, the animation starts at, in pixels as a {@link Float} value
+ */
+ public final float getY() {
+ return y;
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/SwipeAnimation.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/SwipeAnimation.java
new file mode 100755
index 0000000..5b42b00
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/SwipeAnimation.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.view.animation.Interpolator;
+
+import static de.mrapp.android.util.Condition.ensureNotNull;
+
+/**
+ * A swipe animation, which moves tabs on the orthogonal axis, while animating their size and
+ * opacity at the same time. Swipe animations can be used to add or remove tabs to a {@link
+ * TabSwitcher} when using the smartphone layout.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class SwipeAnimation extends Animation {
+
+ /**
+ * Contains all possible directions of a swipe animation.
+ */
+ public enum SwipeDirection {
+
+ /**
+ * When the tab should be swiped in/out from/to the left, respectively the top, when
+ * dragging horizontally.
+ */
+ LEFT,
+
+ /**
+ * When the tab should be swiped in/out from/to the right, respectively the bottom, when
+ * dragging horizontally.
+ */
+ RIGHT
+
+ }
+
+ /**
+ * A builder, which allows to configure and create instances of the class {@link
+ * SwipeAnimation}.
+ */
+ public static class Builder extends Animation.Builder {
+
+ /**
+ * The direction of the animations, which are created by the builder.
+ */
+ private SwipeDirection direction;
+
+ /**
+ * Creates a new builder, which allows to configure and create instances of the class {@link
+ * SwipeAnimation}.
+ */
+ public Builder() {
+ setDirection(SwipeDirection.RIGHT);
+ }
+
+ /**
+ * Sets the direction of the animations, which are created by the builder.
+ *
+ * @param direction
+ * The direction, which should be set, as a value of the enum {@link
+ * SwipeDirection}. The direction may either be {@link SwipeDirection#LEFT} or
+ * {@link SwipeDirection#RIGHT}
+ * @return The builder, this method has be called upon, as an instance of the generic type
+ * BuilderType. The builder may not be null
+ */
+ @NonNull
+ public final Builder setDirection(@NonNull final SwipeDirection direction) {
+ ensureNotNull(direction, "The direction may not be null");
+ this.direction = direction;
+ return self();
+ }
+
+ @NonNull
+ @Override
+ public final SwipeAnimation create() {
+ return new SwipeAnimation(duration, interpolator, direction);
+ }
+
+ }
+
+ /**
+ * The direction of the swipe animation.
+ */
+ private final SwipeDirection direction;
+
+ /**
+ * Creates a new swipe animation.
+ *
+ * @param duration
+ * The duration of the animation in milliseconds as a {@link Long} value or -1, if the
+ * default duration should be used
+ * @param interpolator
+ * The interpolator, which should be used by the animation, as an instance of the type
+ * {@link Interpolator} or null, if the default interpolator should be used
+ * @param direction
+ * The direction of the swipe animation as a value of the enum {@link SwipeDirection}.
+ * The direction may not be null
+ */
+ private SwipeAnimation(final long duration, @Nullable final Interpolator interpolator,
+ @NonNull final SwipeDirection direction) {
+ super(duration, interpolator);
+ ensureNotNull(direction, "The direction may not be null");
+ this.direction = direction;
+ }
+
+ /**
+ * Returns the direction of the swipe animation.
+ *
+ * @return The direction of the swipe animation as a value of the enum {@link SwipeDirection}.
+ * The direction may not be null
+ */
+ @NonNull
+ public final SwipeDirection getDirection() {
+ return direction;
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Tab.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Tab.java
new file mode 100755
index 0000000..2cdab39
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/Tab.java
@@ -0,0 +1,573 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.support.annotation.ColorInt;
+import android.support.annotation.DrawableRes;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.StringRes;
+import android.support.v4.content.ContextCompat;
+import android.text.TextUtils;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import static de.mrapp.android.util.Condition.ensureNotEmpty;
+import static de.mrapp.android.util.Condition.ensureNotNull;
+
+/**
+ * A tab, which can be added to a {@link TabSwitcher} widget. It has a title, as well as an optional
+ * icon. Furthermore, it is possible to set a custom color and to specify, whether the tab should be
+ * closeable, or not.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class Tab implements Parcelable {
+
+ /**
+ * A creator, which allows to create instances of the class {@link Tab} from parcels.
+ */
+ public static final Creator CREATOR = new Creator() {
+
+ @Override
+ public Tab createFromParcel(final Parcel source) {
+ return new Tab(source);
+ }
+
+ @Override
+ public Tab[] newArray(final int size) {
+ return new Tab[size];
+ }
+
+ };
+
+ /**
+ * Defines the interface, a class, which should be notified, when a tab's properties have been
+ * changed, must implement.
+ */
+ public interface Callback {
+
+ /**
+ * The method, which is invoked, when the tab's title has been changed.
+ *
+ * @param tab
+ * The observed tab as an instance of the class {@link Tab}. The tab may not be
+ * null
+ */
+ void onTitleChanged(@NonNull Tab tab);
+
+ /**
+ * The method, which is invoked, when the tab's icon has been changed.
+ *
+ * @param tab
+ * The observed tab as an instance of the class {@link Tab}. The tab may not be
+ * null
+ */
+ void onIconChanged(@NonNull Tab tab);
+
+ /**
+ * The method, which is invoked, when it has been changed, whether the tab is closeable, or
+ * not.
+ *
+ * @param tab
+ * The observed tab as an instance of the class {@link Tab}. The tab may not be
+ * null
+ */
+ void onCloseableChanged(@NonNull Tab tab);
+
+ /**
+ * The method, which is invoked, when the icon of the tab's close button has been changed.
+ *
+ * @param tab
+ * The observed tab as an instance of the class {@link Tab}. The tab may not be
+ * null
+ */
+ void onCloseButtonIconChanged(@NonNull Tab tab);
+
+ /**
+ * The method, which is invoked, when the tab's background color has been changed.
+ *
+ * @param tab
+ * The observed tab as an instance of the class {@link Tab}. The tab may not be
+ * null
+ */
+ void onBackgroundColorChanged(@NonNull Tab tab);
+
+ /**
+ * The method, which is invoked, when the text color of the tab's title has been changed.
+ *
+ * @param tab
+ * The observed tab as an instance of the class {@link Tab}. The tab may not be
+ * null
+ */
+ void onTitleTextColorChanged(@NonNull Tab tab);
+
+ }
+
+ /**
+ * A set, which contains the callbacks, which have been registered to be notified, when the
+ * tab's properties have been changed.
+ */
+ private final Set callbacks = new LinkedHashSet<>();
+
+ /**
+ * The tab's title.
+ */
+ private CharSequence title;
+
+ /**
+ * The resource id of the tab's icon.
+ */
+ private int iconId;
+
+ /**
+ * The tab's icon as a bitmap.
+ */
+ private Bitmap iconBitmap;
+
+ /**
+ * True, if the tab is closeable, false otherwise.
+ */
+ private boolean closeable;
+
+ /**
+ * The resource id of the icon of the tab's close button.
+ */
+ private int closeButtonIconId;
+
+ /**
+ * The bitmap of the icon of the tab's close button.
+ */
+ private Bitmap closeButtonIconBitmap;
+
+ /**
+ * The background color of the tab.
+ */
+ private ColorStateList backgroundColor;
+
+ /**
+ * The text color of the tab's title.
+ */
+ private ColorStateList titleTextColor;
+
+ /**
+ * Optional parameters, which are associated with the tab.
+ */
+ private Bundle parameters;
+
+ /**
+ * Notifies all callbacks, that the tab's title has been changed.
+ */
+ private void notifyOnTitleChanged() {
+ for (Callback callback : callbacks) {
+ callback.onTitleChanged(this);
+ }
+ }
+
+ /**
+ * Notifies all callbacks, that the tab's icon has been changed.
+ */
+ private void notifyOnIconChanged() {
+ for (Callback callback : callbacks) {
+ callback.onIconChanged(this);
+ }
+ }
+
+ /**
+ * Notifies all callbacks, that it has been changed, whether the tab is closeable, or not.
+ */
+ private void notifyOnCloseableChanged() {
+ for (Callback callback : callbacks) {
+ callback.onCloseableChanged(this);
+ }
+ }
+
+ /**
+ * Notifies all callbacks, that the icon of the tab's close button has been changed.
+ */
+ private void notifyOnCloseButtonIconChanged() {
+ for (Callback callback : callbacks) {
+ callback.onCloseButtonIconChanged(this);
+ }
+ }
+
+ /**
+ * Notifies all callbacks, that the background color of the tab has been changed.
+ */
+ private void notifyOnBackgroundColorChanged() {
+ for (Callback callback : callbacks) {
+ callback.onBackgroundColorChanged(this);
+ }
+ }
+
+ /**
+ * Notifies all callbacks, that the text color of the tab has been changed.
+ */
+ private void notifyOnTitleTextColorChanged() {
+ for (Callback callback : callbacks) {
+ callback.onTitleTextColorChanged(this);
+ }
+ }
+
+ /**
+ * Creates a new tab, which can be added to a {@link TabSwitcher} widget.
+ *
+ * @param source
+ * The parcel, the tab should be created from, as an instance of the class {@link
+ * Parcel}. The parcel may not be null
+ */
+ private Tab(@NonNull final Parcel source) {
+ this.title = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(source);
+ this.iconId = source.readInt();
+ this.iconBitmap = source.readParcelable(getClass().getClassLoader());
+ this.closeable = source.readInt() > 0;
+ this.closeButtonIconId = source.readInt();
+ this.closeButtonIconBitmap = source.readParcelable(getClass().getClassLoader());
+ this.backgroundColor = source.readParcelable(getClass().getClassLoader());
+ this.titleTextColor = source.readParcelable(getClass().getClassLoader());
+ this.parameters = source.readBundle(getClass().getClassLoader());
+ }
+
+ /**
+ * Creates a new tab, which can be added to a {@link TabSwitcher} widget.
+ *
+ * @param title
+ * The tab's title as an instance of the type {@link CharSequence}. The title may not be
+ * neither be null, nor empty
+ */
+ public Tab(@NonNull final CharSequence title) {
+ setTitle(title);
+ this.closeable = true;
+ this.closeButtonIconId = -1;
+ this.closeButtonIconBitmap = null;
+ this.iconId = -1;
+ this.iconBitmap = null;
+ this.backgroundColor = null;
+ this.titleTextColor = null;
+ this.parameters = null;
+ }
+
+ /**
+ * Creates a new tab, which can be added to a {@link TabSwitcher} widget.
+ *
+ * @param context
+ * The context, which should be used, as an instance of the class {@link Context}. The
+ * context may not be null
+ * @param resourceId
+ * The resource id of the tab's title as an {@link Integer} value. The resource id must
+ * correspond to a valid string resource
+ */
+ public Tab(@NonNull final Context context, @StringRes final int resourceId) {
+ this(context.getString(resourceId));
+ }
+
+ /**
+ * Returns the tab's title.
+ *
+ * @return The tab's title as an instance of the type {@link CharSequence}. The title may
+ * neither be null, nor empty
+ */
+ @NonNull
+ public final CharSequence getTitle() {
+ return title;
+ }
+
+ /**
+ * Sets the tab's title.
+ *
+ * @param title
+ * The title, which should be set, as an instance of the type {@link CharSequence}. The
+ * title may neither be null, nor empty
+ */
+ public final void setTitle(@NonNull final CharSequence title) {
+ ensureNotNull(title, "The title may not be null");
+ ensureNotEmpty(title, "The title may not be empty");
+ this.title = title;
+ notifyOnTitleChanged();
+ }
+
+ /**
+ * Sets the tab's title.
+ *
+ * @param context
+ * The context, which should be used, as an instance of the class {@link Context}. The
+ * context may not be null
+ * @param resourceId
+ * The resource id of the title, which should be set, as an {@link Integer} value. The
+ * resource id must correspond to a valid string resource
+ */
+ public final void setTitle(@NonNull final Context context, @StringRes final int resourceId) {
+ setTitle(context.getText(resourceId));
+ }
+
+ /**
+ * Returns the tab's icon.
+ *
+ * @param context
+ * The context, which should be used, as an instance of the class {@link Context}. The
+ * context may not be null
+ * @return The tab's icon as an instance of the class {@link Drawable} or null, if no custom
+ * icon is set
+ */
+ @Nullable
+ public final Drawable getIcon(@NonNull final Context context) {
+ ensureNotNull(context, "The context may not be null");
+
+ if (iconId != -1) {
+ return ContextCompat.getDrawable(context, iconId);
+ } else {
+ return iconBitmap != null ? new BitmapDrawable(context.getResources(), iconBitmap) :
+ null;
+ }
+ }
+
+ /**
+ * Sets the tab's icon.
+ *
+ * @param resourceId
+ * The resource id of the icon, which should be set, as an {@link Integer} value. The
+ * resource id must correspond to a valid drawable resource
+ */
+ public final void setIcon(@DrawableRes final int resourceId) {
+ this.iconId = resourceId;
+ this.iconBitmap = null;
+ notifyOnIconChanged();
+ }
+
+ /**
+ * Sets the tab's icon.
+ *
+ * @param icon
+ * The icon, which should be set, as an instance of the class {@link Bitmap} or null, if
+ * no custom icon should be set
+ */
+ public final void setIcon(@Nullable final Bitmap icon) {
+ this.iconId = -1;
+ this.iconBitmap = icon;
+ notifyOnIconChanged();
+ }
+
+ /**
+ * Returns, whether the tab is closeable, or not.
+ *
+ * @return True, if the tab is closeable, false otherwise
+ */
+ public final boolean isCloseable() {
+ return closeable;
+ }
+
+ /**
+ * Sets, whether the tab should be closeable, or not.
+ *
+ * @param closeable
+ * True, if the tab should be closeable, false otherwise
+ */
+ public final void setCloseable(final boolean closeable) {
+ this.closeable = closeable;
+ notifyOnCloseableChanged();
+ }
+
+ /**
+ * Returns the icon of the tab's close button.
+ *
+ * @param context
+ * The context, which should be used to retrieve the icon, as an instance of the class
+ * {@link Context}. The context may not be null
+ * @return The icon of the tab's close button as an instance of the class {@link Drawable} or
+ * null, if no custom icon is set
+ */
+ @Nullable
+ public final Drawable getCloseButtonIcon(@NonNull final Context context) {
+ ensureNotNull(context, "The context may not be null");
+
+ if (closeButtonIconId != -1) {
+ return ContextCompat.getDrawable(context, closeButtonIconId);
+ } else {
+ return closeButtonIconBitmap != null ?
+ new BitmapDrawable(context.getResources(), closeButtonIconBitmap) : null;
+ }
+ }
+
+ /**
+ * Sets the icon of the tab's close button.
+ *
+ * @param resourceId
+ * The resource id of the icon, which should be set, as an {@link Integer} value. The
+ * resource id must correspond to a valid drawable resource
+ */
+ public final void setCloseButtonIcon(@DrawableRes final int resourceId) {
+ this.closeButtonIconId = resourceId;
+ this.closeButtonIconBitmap = null;
+ notifyOnCloseButtonIconChanged();
+ }
+
+ /**
+ * Sets the icon of the tab's close button.
+ *
+ * @param icon
+ * The icon, which should be set, as an instance of the class {@link Bitmap} or null, if
+ * no custom icon should be set
+ */
+ public final void setCloseButtonIcon(@Nullable final Bitmap icon) {
+ this.closeButtonIconId = -1;
+ this.closeButtonIconBitmap = icon;
+ notifyOnCloseButtonIconChanged();
+ }
+
+ /**
+ * Returns the background color of the tab.
+ *
+ * @return The background color of the tab as an instance of the class {@link ColorStateList} or
+ * -1, if no custom color is set
+ */
+ @Nullable
+ public final ColorStateList getBackgroundColor() {
+ return backgroundColor;
+ }
+
+ /**
+ * Sets the tab's background color.
+ *
+ * @param color
+ * The color, which should be set, as an {@link Integer} value or -1, if no custom color
+ * should be set
+ */
+ public final void setBackgroundColor(@ColorInt final int color) {
+ setBackgroundColor(color != -1 ? ColorStateList.valueOf(color) : null);
+ }
+
+ /**
+ * Sets the tab's background color.
+ *
+ * @param colorStateList
+ * The color state list, which should be set, as an instance of the class {@link
+ * ColorStateList} or null, if no custom color should be set
+ */
+ public final void setBackgroundColor(@Nullable final ColorStateList colorStateList) {
+ this.backgroundColor = colorStateList;
+ notifyOnBackgroundColorChanged();
+ }
+
+ /**
+ * Returns the text color of the tab's title.
+ *
+ * @return The text color of the tab's title as an instance of the class {@link ColorStateList}
+ * or null, if no custom color is set
+ */
+ @Nullable
+ public final ColorStateList getTitleTextColor() {
+ return titleTextColor;
+ }
+
+ /**
+ * Sets the text color of the tab's title.
+ *
+ * @param color
+ * The color, which should be set, as an {@link Integer} value or -1, if no custom color
+ * should be set
+ */
+ public final void setTitleTextColor(@ColorInt final int color) {
+ setTitleTextColor(color != -1 ? ColorStateList.valueOf(color) : null);
+ }
+
+ /**
+ * Sets the text color of the tab's title.
+ *
+ * @param colorStateList
+ * The color state list, which should be set, as an instance of the class {@link
+ * ColorStateList} or null, if no custom color should be set
+ */
+ public final void setTitleTextColor(@Nullable final ColorStateList colorStateList) {
+ this.titleTextColor = colorStateList;
+ notifyOnTitleTextColorChanged();
+ }
+
+ /**
+ * Returns a bundle, which contains the optional parameters, which are associated with the tab.
+ *
+ * @return A bundle, which contains the optional parameters, which are associated with the tab,
+ * as an instance of the class {@link Bundle} or null, if no parameters are associated with the
+ * tab
+ */
+ @Nullable
+ public final Bundle getParameters() {
+ return parameters;
+ }
+
+ /**
+ * Sets a bundle, which contains the optional parameters, which should be associated with the
+ * tab.
+ *
+ * @param parameters
+ * The bundle, which should be set, as an instance of the class {@link Bundle} or null,
+ * if no parameters should be associated with the tab
+ */
+ public final void setParameters(@Nullable final Bundle parameters) {
+ this.parameters = parameters;
+ }
+
+ /**
+ * Adds a new callback, which should be notified, when the tab's properties have been changed.
+ *
+ * @param callback
+ * The callback, which should be added, as an instance of the type {@link Callback}. The
+ * callback may not be null
+ */
+ public final void addCallback(@NonNull final Callback callback) {
+ ensureNotNull(callback, "The callback may not be null");
+ this.callbacks.add(callback);
+ }
+
+ /**
+ * Removes a specific callback, which should not be notified, when the tab's properties have
+ * been changed, anymore.
+ *
+ * @param callback
+ * The callback, which should be removed, as an instance of the type {@link Callback}.
+ * The callback may not be null
+ */
+ public final void removeCallback(@NonNull final Callback callback) {
+ ensureNotNull(callback, "The callback may not be null");
+ this.callbacks.remove(callback);
+ }
+
+ @Override
+ public final int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public final void writeToParcel(final Parcel parcel, final int flags) {
+ TextUtils.writeToParcel(title, parcel, flags);
+ parcel.writeInt(iconId);
+ parcel.writeParcelable(iconBitmap, flags);
+ parcel.writeInt(closeable ? 1 : 0);
+ parcel.writeInt(closeButtonIconId);
+ parcel.writeParcelable(closeButtonIconBitmap, flags);
+ parcel.writeParcelable(backgroundColor, flags);
+ parcel.writeParcelable(titleTextColor, flags);
+ parcel.writeBundle(parameters);
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabCloseListener.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabCloseListener.java
new file mode 100755
index 0000000..b48163e
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabCloseListener.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher;
+
+import android.support.annotation.NonNull;
+
+/**
+ * Defines the interface, a class, which should be notified, when a tab is about to be closed by
+ * clicking its close button, must implement.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public interface TabCloseListener {
+
+ /**
+ * The method, which is invoked, when a tab is about to be closed by clicking its close button.
+ *
+ * @param tabSwitcher
+ * The tab switcher, the tab belongs to, as an instance of the class {@link
+ * TabSwitcher}. The tab switcher may not be null
+ * @param tab
+ * The tab, which is about to be closed, as an instance of the class {@link Tab}. The
+ * tab may not be null
+ * @return True, if the tab should be closed, false otherwise
+ */
+ boolean onCloseTab(@NonNull TabSwitcher tabSwitcher, @NonNull Tab tab);
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabPreviewListener.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabPreviewListener.java
new file mode 100755
index 0000000..3c4c2af
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabPreviewListener.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher;
+
+import android.support.annotation.NonNull;
+
+/**
+ * Defines the interface, a class, which should be notified, when the preview of a tab is about to
+ * be loaded, must implement.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public interface TabPreviewListener {
+
+ /**
+ * The method, which is invoked, when the preview of a tab is about to be loaded.
+ *
+ * @param tabSwitcher
+ * The tab switcher, which contains the tab, whose preview is about to be loaded, as an
+ * instance of the class {@link TabSwitcher}. The tab switcher may not be null
+ * @param tab
+ * The tab, whose preview is about to be loaded, as an instance of the class {@link
+ * Tab}. The tab may not be null
+ * @return True, if loading the preview should be proceeded, false otherwise. When returning
+ * false, the method gets invoked repeatedly until true is returned.
+ */
+ boolean onLoadTabPreview(@NonNull TabSwitcher tabSwitcher, @NonNull Tab tab);
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcher.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcher.java
new file mode 100755
index 0000000..37ea16e
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcher.java
@@ -0,0 +1,1454 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.support.annotation.AttrRes;
+import android.support.annotation.ColorInt;
+import android.support.annotation.DrawableRes;
+import android.support.annotation.MenuRes;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.StringRes;
+import android.support.annotation.StyleRes;
+import android.support.v4.content.ContextCompat;
+import android.support.v4.util.Pair;
+import android.support.v4.view.ViewCompat;
+import android.support.v7.widget.Toolbar;
+import android.support.v7.widget.Toolbar.OnMenuItemClickListener;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewTreeObserver.OnGlobalLayoutListener;
+import android.widget.FrameLayout;
+
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.LinkedList;
+import java.util.Queue;
+import java.util.Set;
+
+import de.mrapp.android.tabswitcher.layout.AbstractTabSwitcherLayout;
+import de.mrapp.android.tabswitcher.layout.AbstractTabSwitcherLayout.LayoutListenerWrapper;
+import de.mrapp.android.tabswitcher.layout.TabSwitcherLayout;
+import de.mrapp.android.tabswitcher.layout.phone.PhoneArithmetics;
+import de.mrapp.android.tabswitcher.layout.phone.PhoneTabSwitcherLayout;
+import de.mrapp.android.tabswitcher.model.Model;
+import de.mrapp.android.tabswitcher.model.TabSwitcherModel;
+import de.mrapp.android.tabswitcher.view.TabSwitcherButton;
+import de.mrapp.android.util.DisplayUtil.DeviceType;
+import de.mrapp.android.util.DisplayUtil.Orientation;
+import de.mrapp.android.util.ViewUtil;
+import de.mrapp.android.util.logging.LogLevel;
+import de.mrapp.android.util.view.AbstractSavedState;
+
+import static de.mrapp.android.util.Condition.ensureNotNull;
+import static de.mrapp.android.util.DisplayUtil.getDeviceType;
+import static de.mrapp.android.util.DisplayUtil.getOrientation;
+
+/**
+ * A tab switcher, which allows to switch between multiple tabs. It it is designed similar to the
+ * tab switcher of the Google Chrome Android app.
+ *
+ * In order to specify the appearance of individual tabs, a class, which extends from the abstract
+ * class {@link TabSwitcherDecorator}, must be implemented and set to the tab switcher via the
+ * setDecorator
-method.
+ *
+ * The currently selected tab is shown fullscreen by default. When displaying the switcher via the
+ * showSwitcher-method
, an overview of all tabs is shown, allowing to select an other
+ * tab by clicking it. By swiping a tab or by clicking its close button, it can be removed,
+ * resulting in the selected tab to be altered automatically. The switcher can programmatically be
+ * hidden by calling the hideSwitcher
-method. By calling the
+ * setSelectedTab
-method programmatically, a tab is selected and shown fullscreen.
+ *
+ * Individual tabs are represented by instances of the class {@link Tab}. Such tabs can dynamically
+ * be added to the tab switcher by using the addTab
-methods. In order to remove them
+ * afterwards, the removeTab
can be used. If the switcher is currently shown, calling
+ * these methods results in the tabs being added or removed in an animated manner.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class TabSwitcher extends FrameLayout implements TabSwitcherLayout, Model {
+
+ /**
+ * A saved state, which allows to store the state of a {@link TabSwitcher}.
+ */
+ private static class TabSwitcherState extends AbstractSavedState {
+
+ /**
+ * A creator, which allows to create instances of the class {@link TabSwitcherState}.
+ */
+ public static Creator CREATOR = new Creator() {
+
+ @Override
+ public TabSwitcherState createFromParcel(final Parcel source) {
+ return new TabSwitcherState(source);
+ }
+
+ @Override
+ public TabSwitcherState[] newArray(final int size) {
+ return new TabSwitcherState[size];
+ }
+
+ };
+
+ /**
+ * The saved layout policy, which is used by the tab switcher.
+ */
+ private LayoutPolicy layoutPolicy;
+
+ /**
+ * The saved state of the model, which is used by the tab switcher.
+ */
+ private Bundle modelState;
+
+ /**
+ * Creates a new saved state, which allows to store the state of a {@link TabSwitcher}.
+ *
+ * @param source
+ * The parcel to read read from as a instance of the class {@link Parcel}. The
+ * parcel may not be null
+ */
+ private TabSwitcherState(@NonNull final Parcel source) {
+ super(source);
+ layoutPolicy = (LayoutPolicy) source.readSerializable();
+ modelState = source.readBundle(getClass().getClassLoader());
+ }
+
+ /**
+ * Creates a new saved state, which allows to store the state of a {@link TabSwitcher}.
+ *
+ * @param superState
+ * The state of the superclass of the view, this saved state corresponds to, as an
+ * instance of the type {@link Parcelable} or null if no state is available
+ */
+ TabSwitcherState(@Nullable final Parcelable superState) {
+ super(superState);
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ super.writeToParcel(dest, flags);
+ dest.writeSerializable(layoutPolicy);
+ dest.writeBundle(modelState);
+ }
+
+ }
+
+ /**
+ * A queue, which contains all pending actions.
+ */
+ private Queue pendingActions;
+
+ /**
+ * A set, which contains the listeners, which should be notified about the tab switcher's
+ * events.
+ */
+ private Set listeners;
+
+ /**
+ * The layout policy, which is used by the tab switcher.
+ */
+ private LayoutPolicy layoutPolicy;
+
+ /**
+ * The model, which is used by the tab switcher.
+ */
+ private TabSwitcherModel model;
+
+ /**
+ * The layout, which is used by the tab switcher, depending on whether the device is a
+ * smartphone or tablet and the set layout policy.
+ */
+ private AbstractTabSwitcherLayout layout;
+
+ /**
+ * Initializes the view.
+ *
+ * @param attributeSet
+ * The attribute set, which should be used to initialize the view, as an instance of the
+ * type {@link AttributeSet} or null, if no attributes should be obtained
+ * @param defaultStyle
+ * 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
+ * @param defaultStyleResource
+ * A resource identifier of a style resource that supplies default values for the view,
+ * used only if the default style is 0 or can not be found in the theme. Can be 0 to not
+ * look for defaults
+ */
+ private void initialize(@Nullable final AttributeSet attributeSet,
+ @AttrRes final int defaultStyle,
+ @StyleRes final int defaultStyleResource) {
+ pendingActions = new LinkedList<>();
+ listeners = new LinkedHashSet<>();
+ model = new TabSwitcherModel(this);
+ model.addListener(createModelListener());
+ getViewTreeObserver().addOnGlobalLayoutListener(
+ new LayoutListenerWrapper(this, createGlobalLayoutListener(false)));
+ setPadding(super.getPaddingLeft(), super.getPaddingTop(), super.getPaddingRight(),
+ super.getPaddingBottom());
+ obtainStyledAttributes(attributeSet, defaultStyle, defaultStyleResource);
+ }
+
+ /**
+ * Initializes a specific layout.
+ *
+ * @param inflatedTabsOnly
+ * True, if only the tabs should be inflated, false otherwise
+ * @param layout
+ * The layout, which should be initialized, as a value of the enum {@link Layout}. The
+ * layout may not be null
+ */
+ private void initializeLayout(@NonNull final Layout layout, final boolean inflatedTabsOnly) {
+ if (layout == Layout.TABLET) {
+ // TODO: Use tablet layout once implemented
+ PhoneArithmetics arithmetics = new PhoneArithmetics(TabSwitcher.this);
+ this.layout = new PhoneTabSwitcherLayout(TabSwitcher.this, model, arithmetics);
+ } else {
+ PhoneArithmetics arithmetics = new PhoneArithmetics(TabSwitcher.this);
+ this.layout = new PhoneTabSwitcherLayout(TabSwitcher.this, model, arithmetics);
+ }
+
+ this.layout.setCallback(createLayoutCallback());
+ this.model.addListener(this.layout);
+ this.layout.inflateLayout(inflatedTabsOnly);
+ final ViewGroup tabContainer = getTabContainer();
+ assert tabContainer != null;
+
+ if (ViewCompat.isLaidOut(tabContainer)) {
+ this.layout.onGlobalLayout();
+ } else {
+ tabContainer.getViewTreeObserver()
+ .addOnGlobalLayoutListener(new OnGlobalLayoutListener() {
+
+ @Override
+ public void onGlobalLayout() {
+ ViewUtil.removeOnGlobalLayoutListener(
+ tabContainer.getViewTreeObserver(), this);
+ TabSwitcher.this.layout.onGlobalLayout();
+ }
+
+ });
+ }
+ }
+
+ /**
+ * Obtains all attributes from a specific attribute set.
+ *
+ * @param attributeSet
+ * The attribute set, the attributes should be obtained from, as an instance of the type
+ * {@link AttributeSet} or null, if no attributes should be obtained
+ * @param defaultStyle
+ * 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
+ * @param defaultStyleResource
+ * A resource identifier of a style resource that supplies default values for the view,
+ * used only if the default style is 0 or can not be found in the theme. Can be 0 to not
+ * look for defaults
+ */
+ private void obtainStyledAttributes(@Nullable final AttributeSet attributeSet,
+ @AttrRes final int defaultStyle,
+ @StyleRes final int defaultStyleResource) {
+ TypedArray typedArray = getContext()
+ .obtainStyledAttributes(attributeSet, R.styleable.TabSwitcher, defaultStyle,
+ defaultStyleResource);
+
+ try {
+ obtainLayoutPolicy(typedArray);
+ obtainBackground(typedArray);
+ obtainTabIcon(typedArray);
+ obtainTabBackgroundColor(typedArray);
+ obtainTabTitleTextColor(typedArray);
+ obtainTabCloseButtonIcon(typedArray);
+ obtainToolbarTitle(typedArray);
+ obtainToolbarNavigationIcon(typedArray);
+ obtainToolbarMenu(typedArray);
+ } finally {
+ typedArray.recycle();
+ }
+ }
+
+ /**
+ * Obtains the layout policy from a specific typed array.
+ *
+ * @param typedArray
+ * The typed array, the layout policy should be obtained from, as an instance of the
+ * class {@link TypedArray}. The typed array may not be null
+ */
+ private void obtainLayoutPolicy(@NonNull final TypedArray typedArray) {
+ int defaultValue = LayoutPolicy.AUTO.getValue();
+ int value = typedArray.getInt(R.styleable.TabSwitcher_layoutPolicy, defaultValue);
+ setLayoutPolicy(LayoutPolicy.fromValue(value));
+ }
+
+ /**
+ * Obtains the view's background from a specific typed array.
+ *
+ * @param typedArray
+ * The typed array, the background should be obtained from, as an instance of the class
+ * {@link TypedArray}. The typed array may not be null
+ */
+ private void obtainBackground(@NonNull final TypedArray typedArray) {
+ int resourceId = typedArray.getResourceId(R.styleable.TabSwitcher_android_background, 0);
+
+ if (resourceId != 0) {
+ ViewUtil.setBackground(this, ContextCompat.getDrawable(getContext(), resourceId));
+ } else {
+ int defaultValue =
+ ContextCompat.getColor(getContext(), R.color.tab_switcher_background_color);
+ int color =
+ typedArray.getColor(R.styleable.TabSwitcher_android_background, defaultValue);
+ setBackgroundColor(color);
+ }
+ }
+
+ /**
+ * Obtains the icon of a tab from a specific typed array.
+ *
+ * @param typedArray
+ * The typed array, the icon should be obtained from, as an instance of the class {@link
+ * TypedArray}. The typed array may not be null
+ */
+ private void obtainTabIcon(@NonNull final TypedArray typedArray) {
+ int resourceId = typedArray.getResourceId(R.styleable.TabSwitcher_tabIcon, -1);
+
+ if (resourceId != -1) {
+ setTabIcon(resourceId);
+ }
+ }
+
+ /**
+ * Obtains the background color of a tab from a specific typed array.
+ *
+ * @param typedArray
+ * The typed array, the background color should be obtained from, as an instance of the
+ * class {@link TypedArray}. The typed array may not be null
+ */
+ private void obtainTabBackgroundColor(@NonNull final TypedArray typedArray) {
+ ColorStateList colorStateList =
+ typedArray.getColorStateList(R.styleable.TabSwitcher_tabBackgroundColor);
+
+ if (colorStateList != null) {
+ setTabBackgroundColor(colorStateList);
+ }
+ }
+
+ /**
+ * Obtains the text color of a tab's title from a specific typed array.
+ *
+ * @param typedArray
+ * The typed array, the text color should be obtained from, as an instance of the class
+ * {@link TypedArray}. The typed array may not be null
+ */
+ private void obtainTabTitleTextColor(@NonNull final TypedArray typedArray) {
+ ColorStateList colorStateList =
+ typedArray.getColorStateList(R.styleable.TabSwitcher_tabTitleTextColor);
+
+ if (colorStateList != null) {
+ setTabTitleTextColor(colorStateList);
+ }
+ }
+
+ /**
+ * Obtains the icon of a tab's close button from a specific typed array.
+ *
+ * @param typedArray
+ * The typed array, the icon should be obtained from, as an instance of the class {@link
+ * TypedArray}. The typed array may not be null
+ */
+ private void obtainTabCloseButtonIcon(@NonNull final TypedArray typedArray) {
+ int resourceId = typedArray.getResourceId(R.styleable.TabSwitcher_tabCloseButtonIcon, -1);
+
+ if (resourceId != -1) {
+ setTabCloseButtonIcon(resourceId);
+ }
+ }
+
+ /**
+ * Obtains the title of the toolbar, which is shown, when the tab switcher is shown, from a
+ * specific typed array.
+ *
+ * @param typedArray
+ * The typed array, the title should be obtained from, as an instance of the class
+ * {@link TypedArray}. The typed array may not be null
+ */
+ private void obtainToolbarTitle(@NonNull final TypedArray typedArray) {
+ CharSequence title = typedArray.getText(R.styleable.TabSwitcher_toolbarTitle);
+
+ if (!TextUtils.isEmpty(title)) {
+ setToolbarTitle(title);
+ }
+ }
+
+ /**
+ * Obtains the navigation icon of the toolbar, which is shown, when the tab switcher is shown,
+ * from a specific typed array.
+ *
+ * @param typedArray
+ * The typed array, the navigation icon should be obtained from, as an instance of the
+ * class {@link TypedArray}. The typed array may not be null
+ */
+ private void obtainToolbarNavigationIcon(@NonNull final TypedArray typedArray) {
+ Drawable icon = typedArray.getDrawable(R.styleable.TabSwitcher_toolbarNavigationIcon);
+
+ if (icon != null) {
+ setToolbarNavigationIcon(icon, null);
+ }
+ }
+
+ /**
+ * Obtains the menu of the toolbar, which is shown, when the tab switcher is shown, from a
+ * specific typed array.
+ *
+ * @param typedArray
+ * The typed array, the menu should be obtained from, as an instance of the class {@link
+ * TypedArray}. The typed array may not be null
+ */
+ private void obtainToolbarMenu(@NonNull final TypedArray typedArray) {
+ int resourceId = typedArray.getResourceId(R.styleable.TabSwitcher_toolbarMenu, -1);
+
+ if (resourceId != -1) {
+ inflateToolbarMenu(resourceId, null);
+ }
+ }
+
+ /**
+ * Enqueues a specific action to be executed, when no animation is running.
+ *
+ * @param action
+ * The action, which should be enqueued as an instance of the type {@link Runnable}. The
+ * action may not be null
+ */
+ private void enqueuePendingAction(@NonNull final Runnable action) {
+ ensureNotNull(action, "The action may not be null");
+ pendingActions.add(action);
+ executePendingAction();
+ }
+
+ /**
+ * Executes the next pending action.
+ */
+ private void executePendingAction() {
+ if (!isAnimationRunning()) {
+ final Runnable action = pendingActions.poll();
+
+ if (action != null) {
+ new Runnable() {
+
+ @Override
+ public void run() {
+ action.run();
+ executePendingAction();
+ }
+
+ }.run();
+ }
+ }
+ }
+
+ /**
+ * Creates and returns a listener, which allows to observe, when the tab switcher's model is
+ * modified.
+ *
+ * @return The listener, which has been created, as an instance of the type {@link
+ * Model.Listener}. The listener may not be null
+ */
+ @NonNull
+ private Model.Listener createModelListener() {
+ return new Model.Listener() {
+
+ @Override
+ public void onLogLevelChanged(@NonNull final LogLevel logLevel) {
+
+ }
+
+ @Override
+ public void onDecoratorChanged(@NonNull final TabSwitcherDecorator decorator) {
+
+ }
+
+ @Override
+ public void onSwitcherShown() {
+ notifyOnSwitcherShown();
+ }
+
+ @Override
+ public void onSwitcherHidden() {
+ notifyOnSwitcherHidden();
+ }
+
+ @Override
+ public void onSelectionChanged(final int previousIndex, final int index,
+ @Nullable final Tab selectedTab,
+ final boolean switcherHidden) {
+ notifyOnSelectionChanged(index, selectedTab);
+
+ if (switcherHidden) {
+ notifyOnSwitcherHidden();
+ }
+ }
+
+ @Override
+ public void onTabAdded(final int index, @NonNull final Tab tab,
+ final int previousSelectedTabIndex, final int selectedTabIndex,
+ final boolean switcherVisibilityChanged,
+ @NonNull final Animation animation) {
+ notifyOnTabAdded(index, tab, animation);
+
+ if (previousSelectedTabIndex != selectedTabIndex) {
+ notifyOnSelectionChanged(selectedTabIndex,
+ selectedTabIndex != -1 ? getTab(selectedTabIndex) : null);
+ }
+
+ if (switcherVisibilityChanged) {
+ notifyOnSwitcherHidden();
+ }
+ }
+
+ @Override
+ public void onAllTabsAdded(final int index, @NonNull final Tab[] tabs,
+ final int previousSelectedTabIndex,
+ final int selectedTabIndex,
+ @NonNull final Animation animation) {
+ for (Tab tab : tabs) {
+ notifyOnTabAdded(index, tab, animation);
+ }
+
+ if (previousSelectedTabIndex != selectedTabIndex) {
+ notifyOnSelectionChanged(selectedTabIndex,
+ selectedTabIndex != -1 ? getTab(selectedTabIndex) : null);
+ }
+ }
+
+ @Override
+ public void onTabRemoved(final int index, @NonNull final Tab tab,
+ final int previousSelectedTabIndex, final int selectedTabIndex,
+ @NonNull final Animation animation) {
+ notifyOnTabRemoved(index, tab, animation);
+
+ if (previousSelectedTabIndex != selectedTabIndex) {
+ notifyOnSelectionChanged(selectedTabIndex,
+ selectedTabIndex != -1 ? getTab(selectedTabIndex) : null);
+ }
+ }
+
+ @Override
+ public void onAllTabsRemoved(@NonNull final Tab[] tabs,
+ @NonNull final Animation animation) {
+ notifyOnAllTabsRemoved(tabs, animation);
+ notifyOnSelectionChanged(-1, null);
+ }
+
+ @Override
+ public void onPaddingChanged(final int left, final int top, final int right,
+ final int bottom) {
+
+ }
+
+ @Override
+ public void onTabIconChanged(@Nullable final Drawable icon) {
+
+ }
+
+ @Override
+ public void onTabBackgroundColorChanged(@Nullable final ColorStateList colorStateList) {
+
+ }
+
+ @Override
+ public void onTabTitleColorChanged(@Nullable final ColorStateList colorStateList) {
+
+ }
+
+ @Override
+ public void onTabCloseButtonIconChanged(@Nullable final Drawable icon) {
+
+ }
+
+ @Override
+ public void onToolbarVisibilityChanged(final boolean visible) {
+
+ }
+
+ @Override
+ public void onToolbarTitleChanged(@Nullable final CharSequence title) {
+
+ }
+
+ @Override
+ public void onToolbarNavigationIconChanged(@Nullable final Drawable icon,
+ @Nullable final OnClickListener listener) {
+
+ }
+
+ @Override
+ public void onToolbarMenuInflated(@MenuRes final int resourceId,
+ @Nullable final OnMenuItemClickListener listener) {
+
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns a callback, which allows to observe, when all pending animations of a
+ * layout have been ended.
+ *
+ * @return The callback, which has been created, as an instance of the type {@link
+ * AbstractTabSwitcherLayout.Callback}. The callback may not be null
+ */
+ @NonNull
+ private AbstractTabSwitcherLayout.Callback createLayoutCallback() {
+ return new AbstractTabSwitcherLayout.Callback() {
+
+ @Override
+ public void onAnimationsEnded() {
+ executePendingAction();
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns a listener, which allows to inflate the view's layout once the view is
+ * laid out.
+ *
+ * @param inflateTabsOnly
+ * True, if only the tabs should be inflated, false otherwise
+ * @return The listener, which has been created, as an instance of the type {@link
+ * OnGlobalLayoutListener}. The listener may not be null
+ */
+ @NonNull
+ private OnGlobalLayoutListener createGlobalLayoutListener(final boolean inflateTabsOnly) {
+ return new OnGlobalLayoutListener() {
+
+ @Override
+ public void onGlobalLayout() {
+ ensureNotNull(getDecorator(), "No decorator has been set",
+ IllegalStateException.class);
+ initializeLayout(getLayout(), inflateTabsOnly);
+ }
+
+ };
+ }
+
+ /**
+ * Notifies all listeners, that the tab switcher has been shown.
+ */
+ private void notifyOnSwitcherShown() {
+ for (TabSwitcherListener listener : listeners) {
+ listener.onSwitcherShown(this);
+ }
+ }
+
+ /**
+ * Notifies all listeners, that the tab switcher has been hidden.
+ */
+ private void notifyOnSwitcherHidden() {
+ for (TabSwitcherListener listener : listeners) {
+ listener.onSwitcherHidden(this);
+ }
+ }
+
+ /**
+ * Notifies all listeners, that the selected tab has been changed.
+ *
+ * @param selectedTabIndex
+ * The index of the currently selected tab as an {@link Integer} value or -1, if no tab
+ * is currently selected
+ * @param selectedTab
+ * The currently selected tab as an instance of the class {@link Tab} or null, if no
+ * tab is currently selected
+ */
+ private void notifyOnSelectionChanged(final int selectedTabIndex,
+ @Nullable final Tab selectedTab) {
+ for (TabSwitcherListener listener : listeners) {
+ listener.onSelectionChanged(this, selectedTabIndex, selectedTab);
+ }
+ }
+
+ /**
+ * Notifies all listeners, that a specific tab has been added to the tab switcher.
+ *
+ * @param index
+ * The index of the tab, which has been added, as an {@link Integer} value
+ * @param tab
+ * The tab, which has been added, as an instance of the class {@link Tab}. The tab may
+ * not be null
+ * @param animation
+ * The animation, which has been used to add the tab, as an instance of the class {@link
+ * Animation}. The animation may not be null
+ */
+ private void notifyOnTabAdded(final int index, @NonNull final Tab tab,
+ @NonNull final Animation animation) {
+ for (TabSwitcherListener listener : listeners) {
+ listener.onTabAdded(this, index, tab, animation);
+ }
+ }
+
+ /**
+ * Notifies all listeners, that a specific tab has been removed from the tab switcher.
+ *
+ * @param index
+ * The index of the tab, which has been removed, as an {@link Integer} value
+ * @param tab
+ * The tab, which has been removed, as an instance of the class {@link Tab}. The tab may
+ * not be null
+ * @param animation
+ * The animation, which has been used to remove the tab, as an instance of the class
+ * {@link Animation}. The animation may not be null
+ */
+ private void notifyOnTabRemoved(final int index, @NonNull final Tab tab,
+ @NonNull final Animation animation) {
+ for (TabSwitcherListener listener : listeners) {
+ listener.onTabRemoved(this, index, tab, animation);
+ }
+ }
+
+ /**
+ * Notifies all listeners, that all tabs have been removed from the tab switcher.
+ *
+ * @param tabs
+ * An array, which contains the tabs, which have been removed, as an array of the type
+ * {@link Tab} or an empty array, if no tabs have been removed
+ * @param animation
+ * The animation, which has been used to remove the tabs, as an instance of the class
+ * {@link Animation}. The animation may not be null
+ */
+ private void notifyOnAllTabsRemoved(@NonNull final Tab[] tabs,
+ @NonNull final Animation animation) {
+ for (TabSwitcherListener listener : listeners) {
+ listener.onAllTabsRemoved(this, tabs, animation);
+ }
+ }
+
+ /**
+ * Creates a new tab switcher, which allows to switch between multiple tabs.
+ *
+ * @param context
+ * The context, which should be used by the view, as an instance of the class {@link
+ * Context}. The context may not be null
+ */
+ public TabSwitcher(@NonNull final Context context) {
+ this(context, null);
+ }
+
+ /**
+ * Creates a new tab switcher, which allows to switch between multiple tabs.
+ *
+ * @param context
+ * The context, which should be used by the view, as an instance of the class {@link
+ * Context}. The context may not be null
+ * @param attributeSet
+ * The attribute set, the view's attributes should be obtained from, as an instance of
+ * the type {@link AttributeSet} or null, if no attributes should be obtained
+ */
+ public TabSwitcher(@NonNull final Context context, @Nullable final AttributeSet attributeSet) {
+ super(context, attributeSet);
+ initialize(attributeSet, 0, 0);
+ }
+
+ /**
+ * Creates a new tab switcher, which allows to switch between multiple tabs.
+ *
+ * @param context
+ * The context, which should be used by the view, as an instance of the class {@link
+ * Context}. The context may not be null
+ * @param attributeSet
+ * The attribute set, the view's attributes should be obtained from, as an instance of
+ * the type {@link AttributeSet} or null, if no attributes should be obtained
+ * @param defaultStyle
+ * 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 TabSwitcher(@NonNull final Context context, @Nullable final AttributeSet attributeSet,
+ @AttrRes final int defaultStyle) {
+ super(context, attributeSet, defaultStyle);
+ initialize(attributeSet, defaultStyle, 0);
+ }
+
+ /**
+ * Creates a new tab switcher, which allows to switch between multiple tabs.
+ *
+ * @param context
+ * The context, which should be used by the view, as an instance of the class {@link
+ * Context}. The context may not be null
+ * @param attributeSet
+ * The attribute set, the view's attributes should be obtained from, as an instance of
+ * the type {@link AttributeSet} or null, if no attributes should be obtained
+ * @param defaultStyle
+ * 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
+ * @param defaultStyleResource
+ * A resource identifier of a style resource that supplies default values for the view,
+ * used only if the default style is 0 or can not be found in the theme. Can be 0 to not
+ * look for defaults
+ */
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public TabSwitcher(@NonNull final Context context, @Nullable final AttributeSet attributeSet,
+ @AttrRes final int defaultStyle, @StyleRes final int defaultStyleResource) {
+ super(context, attributeSet, defaultStyle, defaultStyleResource);
+ initialize(attributeSet, defaultStyle, defaultStyleResource);
+ }
+
+ /**
+ * Setups the tab switcher to be associated with those menu items of a specific menu, which use
+ * a {@link TabSwitcherButton} as their action view. The icon of such menu items will
+ * automatically be updated, when the number of tabs, which are contained by the tab switcher,
+ * changes.
+ *
+ * @param tabSwitcher
+ * The tab switcher, which should become associated with the menu items, as an instance
+ * of the class {@link TabSwitcher}. The tab switcher may not be null
+ * @param menu
+ * The menu, whose menu items should become associated with the given tab switcher, as
+ * an instance of the type {@link Menu}. The menu may not be null
+ * @param listener
+ * The listener, which should be set to the menu items, which use a {@link
+ * TabSwitcherButton} as their action view, as an instance of the type {@link
+ * OnClickListener} or null, if no listener should be set
+ */
+ public static void setupWithMenu(@NonNull final TabSwitcher tabSwitcher,
+ @NonNull final Menu menu,
+ @Nullable final OnClickListener listener) {
+ ensureNotNull(tabSwitcher, "The tab switcher may not be null");
+ ensureNotNull(menu, "The menu may not be null");
+
+ for (int i = 0; i < menu.size(); i++) {
+ MenuItem menuItem = menu.getItem(i);
+ View view = menuItem.getActionView();
+
+ if (view instanceof TabSwitcherButton) {
+ TabSwitcherButton tabSwitcherButton = (TabSwitcherButton) view;
+ tabSwitcherButton.setOnClickListener(listener);
+ tabSwitcherButton.setCount(tabSwitcher.getCount());
+ tabSwitcher.addListener(tabSwitcherButton);
+ }
+ }
+ }
+
+ /**
+ * Adds a listener, which should be notified about the tab switcher's events.
+ *
+ * @param listener
+ * The listener, which should be added, as an instance of the type {@link
+ * TabSwitcherListener}. The listener may not be null
+ */
+ public final void addListener(@NonNull final TabSwitcherListener listener) {
+ ensureNotNull(listener, "The listener may not be null");
+ this.listeners.add(listener);
+ }
+
+ /**
+ * Removes a specific listener, which should not be notified about the tab switcher's events,
+ * anymore.
+ *
+ * @param listener
+ * The listener, which should be removed, as an instance of the type {@link
+ * TabSwitcherListener}. The listener may not be null
+ */
+ public final void removeListener(@NonNull final TabSwitcherListener listener) {
+ ensureNotNull(listener, "The listener may not be null");
+ this.listeners.remove(listener);
+ }
+
+ /**
+ * Returns the layout policy, which is used by the tab switcher.
+ *
+ * @return The layout policy, which is used by the tab switcher, as a value of the enum {@link
+ * LayoutPolicy}. The layout policy may either be {@link LayoutPolicy#AUTO}, {@link
+ * LayoutPolicy#PHONE} or {@link LayoutPolicy#TABLET}
+ */
+ @NonNull
+ public final LayoutPolicy getLayoutPolicy() {
+ return layoutPolicy;
+ }
+
+ /**
+ * Sets the layout policy, which should be used by the tab switcher.
+ *
+ * Changing the layout policy after the view has been laid out does not have any effect.
+ *
+ * @param layoutPolicy
+ * The layout policy, which should be set, as a value of the enum {@link LayoutPolicy}.
+ * The layout policy may either be {@link LayoutPolicy#AUTO}, {@link LayoutPolicy#PHONE}
+ * or {@link LayoutPolicy#TABLET}
+ */
+ public final void setLayoutPolicy(@NonNull final LayoutPolicy layoutPolicy) {
+ ensureNotNull(layoutPolicy, "The layout policy may not be null");
+
+ if (this.layoutPolicy != layoutPolicy) {
+ Layout previousLayout = getLayout();
+ this.layoutPolicy = layoutPolicy;
+
+ if (layout != null) {
+ Layout newLayout = getLayout();
+
+ if (previousLayout != newLayout) {
+ layout.detachLayout(false);
+ model.removeListener(layout);
+ initializeLayout(newLayout, false);
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns the layout of the tab switcher.
+ *
+ * @return The layout of the tab switcher as a value of the enum {@link Layout}. The layout may
+ * either be {@link Layout#PHONE_PORTRAIT}, {@link Layout#PHONE_LANDSCAPE} or {@link
+ * Layout#TABLET}
+ */
+ @NonNull
+ public final Layout getLayout() {
+ if (layoutPolicy == LayoutPolicy.TABLET || (layoutPolicy == LayoutPolicy.AUTO &&
+ getDeviceType(getContext()) == DeviceType.TABLET)) {
+ return Layout.TABLET;
+ } else {
+ return getOrientation(getContext()) == Orientation.LANDSCAPE ? Layout.PHONE_LANDSCAPE :
+ Layout.PHONE_PORTRAIT;
+ }
+ }
+
+ @Override
+ public final void addTab(@NonNull final Tab tab) {
+ enqueuePendingAction(new Runnable() {
+
+ @Override
+ public void run() {
+ model.addTab(tab);
+ }
+
+ });
+ }
+
+ @Override
+ public final void addTab(@NonNull final Tab tab, final int index) {
+ enqueuePendingAction(new Runnable() {
+
+ @Override
+ public void run() {
+ model.addTab(tab, index);
+ }
+
+ });
+ }
+
+ @Override
+ public final void addTab(@NonNull final Tab tab, final int index,
+ @NonNull final Animation animation) {
+ enqueuePendingAction(new Runnable() {
+
+ @Override
+ public void run() {
+ model.addTab(tab, index, animation);
+ }
+
+ });
+ }
+
+ @Override
+ public final void addAllTabs(@NonNull final Collection extends Tab> tabs) {
+ enqueuePendingAction(new Runnable() {
+
+ @Override
+ public void run() {
+ model.addAllTabs(tabs);
+ }
+
+ });
+ }
+
+ @Override
+ public final void addAllTabs(@NonNull final Collection extends Tab> tabs, final int index) {
+ enqueuePendingAction(new Runnable() {
+
+ @Override
+ public void run() {
+ model.addAllTabs(tabs, index);
+ }
+
+ });
+ }
+
+ @Override
+ public final void addAllTabs(@NonNull final Collection extends Tab> tabs, final int index,
+ @NonNull final Animation animation) {
+ enqueuePendingAction(new Runnable() {
+
+ @Override
+ public void run() {
+ model.addAllTabs(tabs, index, animation);
+ }
+
+ });
+ }
+
+ @Override
+ public final void addAllTabs(@NonNull final Tab[] tabs) {
+ enqueuePendingAction(new Runnable() {
+
+ @Override
+ public void run() {
+ model.addAllTabs(tabs);
+ }
+
+ });
+ }
+
+ @Override
+ public final void addAllTabs(@NonNull final Tab[] tabs, final int index) {
+ enqueuePendingAction(new Runnable() {
+
+ @Override
+ public void run() {
+ model.addAllTabs(tabs, index);
+ }
+
+ });
+ }
+
+ @Override
+ public final void addAllTabs(@NonNull final Tab[] tabs, final int index,
+ @NonNull final Animation animation) {
+ enqueuePendingAction(new Runnable() {
+
+ @Override
+ public void run() {
+ model.addAllTabs(tabs, index, animation);
+ }
+
+ });
+ }
+
+ @Override
+ public final void removeTab(@NonNull final Tab tab) {
+ enqueuePendingAction(new Runnable() {
+
+ @Override
+ public void run() {
+ model.removeTab(tab);
+ }
+
+ });
+ }
+
+ @Override
+ public final void removeTab(@NonNull final Tab tab, @NonNull final Animation animation) {
+ enqueuePendingAction(new Runnable() {
+
+ @Override
+ public void run() {
+ model.removeTab(tab, animation);
+ }
+
+ });
+ }
+
+ @Override
+ public final void clear() {
+ enqueuePendingAction(new Runnable() {
+
+ @Override
+ public void run() {
+ model.clear();
+ }
+
+ });
+ }
+
+ @Override
+ public final void clear(@NonNull final Animation animationType) {
+ enqueuePendingAction(new Runnable() {
+
+ @Override
+ public void run() {
+ model.clear(animationType);
+ }
+
+ });
+ }
+
+ @Override
+ public final void selectTab(@NonNull final Tab tab) {
+ enqueuePendingAction(new Runnable() {
+
+ @Override
+ public void run() {
+ model.selectTab(tab);
+ }
+
+ });
+ }
+
+ @Nullable
+ @Override
+ public final Tab getSelectedTab() {
+ return model.getSelectedTab();
+ }
+
+ @Override
+ public final int getSelectedTabIndex() {
+ return model.getSelectedTabIndex();
+ }
+
+ @Override
+ public final Iterator iterator() {
+ return model.iterator();
+ }
+
+ @Override
+ public final boolean isEmpty() {
+ return model.isEmpty();
+ }
+
+ @Override
+ public final int getCount() {
+ return model.getCount();
+ }
+
+ @NonNull
+ @Override
+ public final Tab getTab(final int index) {
+ return model.getTab(index);
+ }
+
+ @Override
+ public final int indexOf(@NonNull final Tab tab) {
+ return model.indexOf(tab);
+ }
+
+ @Override
+ public final boolean isSwitcherShown() {
+ return model.isSwitcherShown();
+ }
+
+ @Override
+ public final void showSwitcher() {
+ enqueuePendingAction(new Runnable() {
+
+ @Override
+ public void run() {
+ model.showSwitcher();
+ }
+
+ });
+ }
+
+ @Override
+ public final void hideSwitcher() {
+ enqueuePendingAction(new Runnable() {
+
+ @Override
+ public void run() {
+ model.hideSwitcher();
+ }
+
+ });
+ }
+
+ @Override
+ public final void toggleSwitcherVisibility() {
+ enqueuePendingAction(new Runnable() {
+
+ @Override
+ public void run() {
+ model.toggleSwitcherVisibility();
+ }
+
+ });
+ }
+
+ @Override
+ public final void setDecorator(@NonNull final TabSwitcherDecorator decorator) {
+ model.setDecorator(decorator);
+ }
+
+ @Override
+ public final TabSwitcherDecorator getDecorator() {
+ return model.getDecorator();
+ }
+
+ @NonNull
+ @Override
+ public final LogLevel getLogLevel() {
+ return model.getLogLevel();
+ }
+
+ @Override
+ public final void setLogLevel(@NonNull final LogLevel logLevel) {
+ model.setLogLevel(logLevel);
+ }
+
+ @Override
+ public final void setPadding(final int left, final int top, final int right, final int bottom) {
+ model.setPadding(left, top, right, bottom);
+ }
+
+ @Override
+ public final int getPaddingLeft() {
+ return model.getPaddingLeft();
+ }
+
+ @Override
+ public final int getPaddingTop() {
+ return model.getPaddingTop();
+ }
+
+ @Override
+ public final int getPaddingRight() {
+ return model.getPaddingRight();
+ }
+
+ @Override
+ public final int getPaddingBottom() {
+ return model.getPaddingBottom();
+ }
+
+ @Override
+ public final int getPaddingStart() {
+ return model.getPaddingStart();
+ }
+
+ @Override
+ public final int getPaddingEnd() {
+ return model.getPaddingEnd();
+ }
+
+ @Nullable
+ @Override
+ public final Drawable getTabIcon() {
+ return model.getTabIcon();
+ }
+
+ @Override
+ public final void setTabIcon(@DrawableRes final int resourceId) {
+ model.setTabIcon(resourceId);
+ }
+
+ @Override
+ public final void setTabIcon(@Nullable final Bitmap icon) {
+ model.setTabIcon(icon);
+ }
+
+ @Nullable
+ @Override
+ public final ColorStateList getTabBackgroundColor() {
+ return model.getTabBackgroundColor();
+ }
+
+ @Override
+ public final void setTabBackgroundColor(@ColorInt final int color) {
+ model.setTabBackgroundColor(color);
+ }
+
+ @Override
+ public final void setTabBackgroundColor(@Nullable final ColorStateList colorStateList) {
+ model.setTabBackgroundColor(colorStateList);
+ }
+
+ @Nullable
+ @Override
+ public final ColorStateList getTabTitleTextColor() {
+ return model.getTabTitleTextColor();
+ }
+
+ @Override
+ public final void setTabTitleTextColor(@ColorInt final int color) {
+ model.setTabTitleTextColor(color);
+ }
+
+ @Override
+ public final void setTabTitleTextColor(@Nullable final ColorStateList colorStateList) {
+ model.setTabTitleTextColor(colorStateList);
+ }
+
+ @Nullable
+ @Override
+ public final Drawable getTabCloseButtonIcon() {
+ return model.getTabCloseButtonIcon();
+ }
+
+ @Override
+ public final void setTabCloseButtonIcon(@DrawableRes final int resourceId) {
+ model.setTabCloseButtonIcon(resourceId);
+ }
+
+ @Override
+ public final void setTabCloseButtonIcon(@Nullable final Bitmap icon) {
+ model.setTabCloseButtonIcon(icon);
+ }
+
+ @Override
+ public final boolean areToolbarsShown() {
+ return model.areToolbarsShown();
+ }
+
+ @Override
+ public final void showToolbars(final boolean show) {
+ model.showToolbars(show);
+ }
+
+ @Nullable
+ @Override
+ public final CharSequence getToolbarTitle() {
+ Toolbar[] toolbars = getToolbars();
+ return toolbars != null ? toolbars[0].getTitle() : model.getToolbarTitle();
+ }
+
+ @Override
+ public final void setToolbarTitle(@StringRes final int resourceId) {
+ model.setToolbarTitle(resourceId);
+ }
+
+ @Override
+ public final void setToolbarTitle(@Nullable final CharSequence title) {
+ model.setToolbarTitle(title);
+ }
+
+ @Nullable
+ @Override
+ public final Drawable getToolbarNavigationIcon() {
+ Toolbar[] toolbars = getToolbars();
+ return toolbars != null ? toolbars[0].getNavigationIcon() :
+ model.getToolbarNavigationIcon();
+ }
+
+ @Override
+ public final void setToolbarNavigationIcon(@Nullable final Drawable icon,
+ @Nullable final OnClickListener listener) {
+ model.setToolbarNavigationIcon(icon, listener);
+ }
+
+ @Override
+ public final void setToolbarNavigationIcon(@DrawableRes final int resourceId,
+ @Nullable final OnClickListener listener) {
+ model.setToolbarNavigationIcon(resourceId, listener);
+ }
+
+ @Override
+ public final void inflateToolbarMenu(@MenuRes final int resourceId,
+ @Nullable final OnMenuItemClickListener listener) {
+ model.inflateToolbarMenu(resourceId, listener);
+ }
+
+ @Override
+ public final void addCloseTabListener(@NonNull final TabCloseListener listener) {
+ model.addCloseTabListener(listener);
+ }
+
+ @Override
+ public final void removeCloseTabListener(@NonNull final TabCloseListener listener) {
+ model.removeCloseTabListener(listener);
+ }
+
+ @Override
+ public final void addTabPreviewListener(@NonNull final TabPreviewListener listener) {
+ model.addTabPreviewListener(listener);
+ }
+
+ @Override
+ public final void removeTabPreviewListener(@NonNull final TabPreviewListener listener) {
+ model.removeTabPreviewListener(listener);
+ }
+
+ @Override
+ public final boolean isAnimationRunning() {
+ return layout != null && layout.isAnimationRunning();
+ }
+
+ @Nullable
+ @Override
+ public final ViewGroup getTabContainer() {
+ return layout != null ? layout.getTabContainer() : null;
+ }
+
+ @Override
+ public final Toolbar[] getToolbars() {
+ return layout != null ? layout.getToolbars() : null;
+ }
+
+ @Nullable
+ @Override
+ public final Menu getToolbarMenu() {
+ return layout != null ? layout.getToolbarMenu() : null;
+ }
+
+ @Override
+ public final boolean onTouchEvent(final MotionEvent event) {
+ return (layout != null && layout.handleTouchEvent(event)) || super.onTouchEvent(event);
+ }
+
+ @Override
+ public final Parcelable onSaveInstanceState() {
+ Parcelable superState = super.onSaveInstanceState();
+ TabSwitcherState savedState = new TabSwitcherState(superState);
+ savedState.layoutPolicy = layoutPolicy;
+ savedState.modelState = new Bundle();
+ Pair pair = layout.detachLayout(true);
+
+ if (pair != null) {
+ savedState.modelState
+ .putInt(TabSwitcherModel.FIRST_VISIBLE_TAB_INDEX_EXTRA, pair.first);
+ savedState.modelState
+ .putFloat(TabSwitcherModel.FIRST_VISIBLE_TAB_POSITION_EXTRA, pair.second);
+ model.setFirstVisibleTabIndex(pair.first);
+ model.setFirstVisibleTabPosition(pair.second);
+ } else {
+ model.setFirstVisibleTabPosition(-1);
+ model.setFirstVisibleTabIndex(-1);
+ }
+
+ model.removeListener(layout);
+ layout = null;
+ executePendingAction();
+ getViewTreeObserver().addOnGlobalLayoutListener(
+ new LayoutListenerWrapper(this, createGlobalLayoutListener(true)));
+ model.saveInstanceState(savedState.modelState);
+ return savedState;
+ }
+
+ @Override
+ public final void onRestoreInstanceState(final Parcelable state) {
+ if (state instanceof TabSwitcherState) {
+ TabSwitcherState savedState = (TabSwitcherState) state;
+ this.layoutPolicy = savedState.layoutPolicy;
+ model.restoreInstanceState(savedState.modelState);
+ super.onRestoreInstanceState(savedState.getSuperState());
+ } else {
+ super.onRestoreInstanceState(state);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcherDecorator.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcherDecorator.java
new file mode 100755
index 0000000..8038a5a
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcherDecorator.java
@@ -0,0 +1,243 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.util.SparseArray;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import de.mrapp.android.util.view.AbstractViewHolderAdapter;
+
+/**
+ * An abstract base class for all decorators, which are responsible for inflating views, which
+ * should be used to visualize the tabs of a {@link TabSwitcher}.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public abstract class TabSwitcherDecorator extends AbstractViewHolderAdapter {
+
+ /**
+ * The name of the extra, which is used to store the state of a view hierarchy within a bundle.
+ */
+ private static final String VIEW_HIERARCHY_STATE_EXTRA =
+ TabSwitcherDecorator.class.getName() + "::ViewHierarchyState";
+
+ /**
+ * The method which is invoked, when a view, which is used to visualize a tab, should be
+ * inflated.
+ *
+ * @param inflater
+ * The inflater, which should be used to inflate the view, as an instance of the class
+ * {@link LayoutInflater}. The inflater may not be null
+ * @param parent
+ * The parent view of the view, which should be inflated, as an instance of the class
+ * {@link ViewGroup} or null, if no parent view is available
+ * @param viewType
+ * The view type of the tab, which should be visualized, as an {@link Integer} value
+ * @return The view, which has been inflated, as an instance of the class {@link View}. The view
+ * may not be null
+ */
+ @NonNull
+ public abstract View onInflateView(@NonNull final LayoutInflater inflater,
+ @Nullable final ViewGroup parent, final int viewType);
+
+ /**
+ * The method which is invoked, when the view, which is used to visualize a tab, should be
+ * shown, respectively when it should be refreshed. The purpose of this method is to customize
+ * the appearance of the view, which is used to visualize the corresponding tab, depending on
+ * its state and whether the tab switcher is currently shown, or not.
+ *
+ * @param context
+ * The context, the tab switcher belongs to, as an instance of the class {@link
+ * Context}. The context may not be null
+ * @param tabSwitcher
+ * The tab switcher, whose tabs are visualized by the decorator, as an instance of the
+ * type {@link TabSwitcher}. The tab switcher may not be null
+ * @param view
+ * The view, which is used to visualize the tab, as an instance of the class {@link
+ * View}. The view may not be null
+ * @param tab
+ * The tab, which should be visualized, as an instance of the class {@link Tab}. The tab
+ * may not be null
+ * @param index
+ * The index of the tab, which should be visualized, as an {@link Integer} value
+ * @param viewType
+ * The view type of the tab, which should be visualized, as an {@link Integer} value
+ * @param savedInstanceState
+ * The bundle, which has previously been used to save the state of the view as an
+ * instance of the class {@link Bundle} or null, if no saved state is available
+ */
+ public abstract void onShowTab(@NonNull final Context context,
+ @NonNull final TabSwitcher tabSwitcher, @NonNull final View view,
+ @NonNull final Tab tab, final int index, final int viewType,
+ @Nullable final Bundle savedInstanceState);
+
+ /**
+ * The method, which is invoked, when the view, which is used to visualize a tab, is removed.
+ * The purpose of this method is to save the current state of the tab in a bundle.
+ *
+ * @param view
+ * The view, which is used to visualize the tab, as an instance of the class {@link
+ * View}
+ * @param tab
+ * The tab, whose state should be saved, as an instance of the class {@link Tab}. The
+ * tab may not be null
+ * @param index
+ * The index of the tab, whose state should be saved, as an {@link Integer} value
+ * @param viewType
+ * The view type of the tab, whose state should be saved, as an {@link Integer} value
+ * @param outState
+ * The bundle, the state of the tab should be saved to, as an instance of the class
+ * {@link Bundle}. The bundle may not be null
+ */
+ public void onSaveInstanceState(@NonNull final View view, @NonNull final Tab tab,
+ final int index, final int viewType,
+ @NonNull final Bundle outState) {
+
+ }
+
+ /**
+ * Returns the view type, which corresponds to a specific tab. For each layout, which is
+ * inflated by the onInflateView
-method, a distinct view type must be
+ * returned.
+ *
+ * @param tab
+ * The tab, whose view type should be returned, as an instance of the class {@link Tab}.
+ * The tab may not be null
+ * @param index
+ * The index of the tab, whose view type should be returned, as an {@link Integer}
+ * value
+ * @return The view type, which corresponds to the given tab, as an {@link Integer} value
+ */
+ public int getViewType(@NonNull final Tab tab, final int index) {
+ return 0;
+ }
+
+ /**
+ * Returns the number of view types, which are used by the decorator.
+ *
+ * @return The number of view types, which are used by the decorator, as an {@link Integer}
+ * value. The number of view types must correspond to the number of distinct values, which are
+ * returned by the getViewType
-method
+ */
+ public int getViewTypeCount() {
+ return 1;
+ }
+
+ /**
+ * The method, which is invoked by a {@link TabSwitcher} to inflate the view, which should be
+ * used to visualize a specific tab.
+ *
+ * @param inflater
+ * The inflater, which should be used to inflate the view, as an instance of the class
+ * {@link LayoutInflater}. The inflater may not be null
+ * @param parent
+ * The parent view of the view, which should be inflated, as an instance of the class
+ * {@link ViewGroup} or null, if no parent view is available
+ * @param tab
+ * The tab, which should be visualized, as an instance of the class {@link Tab}. The tab
+ * may not be null
+ * @param index
+ * The index of the tab, which should be visualized, as an {@link Integer} value
+ * @return The view, which has been inflated, as an instance of the class {@link View}. The view
+ * may not be null
+ */
+ @NonNull
+ public final View inflateView(@NonNull final LayoutInflater inflater,
+ @Nullable final ViewGroup parent, @NonNull final Tab tab,
+ final int index) {
+ int viewType = getViewType(tab, index);
+ return onInflateView(inflater, parent, viewType);
+ }
+
+ /**
+ * The method, which is invoked by a {@link TabSwitcher} to apply the decorator. It initializes
+ * the view holder pattern, which is provided by the decorator and then delegates the method
+ * call to the decorator's custom implementation of the method onShowTab(...):void
.
+ *
+ * @param context
+ * The context, the tab switcher belongs to, as an instance of the class {@link
+ * Context}. The context may not be null
+ * @param tabSwitcher
+ * The tab switcher, whose tabs are visualized by the decorator, as an instance of the
+ * class {@link TabSwitcher}. The tab switcher may not be null
+ * @param view
+ * The view, which is used to visualize the tab, as an instance of the class {@link
+ * View}. The view may not be null
+ * @param tab
+ * The tab, which should be visualized, as an instance of the class {@link Tab}. The tab
+ * may not be null
+ * @param index
+ * The index of the tab, which should be visualized, as an {@link Integer} value
+ * @param savedInstanceState
+ * The bundle, which has previously been used to save the state of the view as an
+ * instance of the class {@link Bundle} or null, if no saved state is available
+ */
+ public final void applyDecorator(@NonNull final Context context,
+ @NonNull final TabSwitcher tabSwitcher,
+ @NonNull final View view, @NonNull final Tab tab,
+ final int index, @Nullable final Bundle savedInstanceState) {
+ setCurrentParentView(view);
+ int viewType = getViewType(tab, index);
+
+ if (savedInstanceState != null) {
+ SparseArray viewStates =
+ savedInstanceState.getSparseParcelableArray(VIEW_HIERARCHY_STATE_EXTRA);
+
+ if (viewStates != null) {
+ view.restoreHierarchyState(viewStates);
+ }
+ }
+
+ onShowTab(context, tabSwitcher, view, tab, index, viewType, savedInstanceState);
+ }
+
+ /**
+ * The method, which is invoked by a {@link TabSwitcher} to save the current state of a tab. It
+ * initializes the view holder pattern, which is provided by the decorator and then delegates
+ * the method call to the decorator's custom implementation of the method
+ * onSaveInstanceState(...):void
.
+ *
+ * @param view
+ * The view, which is used to visualize the tab, as an instance of the class {@link
+ * View}
+ * @param tab
+ * The tab, whose state should be saved, as an instance of the class {@link Tab}. The
+ * tab may not be null
+ * @param index
+ * The index of the tab, whose state should be saved, as an {@link Integer} value
+ * @return The bundle, which has been used to save the state, as an instance of the class {@link
+ * Bundle}. The bundle may not be null
+ */
+ @NonNull
+ public final Bundle saveInstanceState(@NonNull final View view, @NonNull final Tab tab,
+ final int index) {
+ setCurrentParentView(view);
+ int viewType = getViewType(tab, index);
+ Bundle outState = new Bundle();
+ SparseArray viewStates = new SparseArray<>();
+ view.saveHierarchyState(viewStates);
+ outState.putSparseParcelableArray(VIEW_HIERARCHY_STATE_EXTRA, viewStates);
+ onSaveInstanceState(view, tab, index, viewType, outState);
+ return outState;
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcherListener.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcherListener.java
new file mode 100755
index 0000000..00a11c3
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/TabSwitcherListener.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+/**
+ * Defines the interface, a class, which should be notified about a tab switcher's events, must
+ * implement.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public interface TabSwitcherListener {
+
+ /**
+ * The method, which is invoked, when the tab switcher has been shown.
+ *
+ * @param tabSwitcher
+ * The observed tab switcher as an instance of the class {@link TabSwitcher}. The tab
+ * switcher may not be null
+ */
+ void onSwitcherShown(@NonNull TabSwitcher tabSwitcher);
+
+ /**
+ * The method, which is invoked, when the tab switcher has been hidden.
+ *
+ * @param tabSwitcher
+ * The observed tab switcher as an instance of the class {@link TabSwitcher}. The tab
+ * switcher may not be null
+ */
+ void onSwitcherHidden(@NonNull TabSwitcher tabSwitcher);
+
+ /**
+ * The method, which is invoked, when the currently selected tab has been changed.
+ *
+ * @param tabSwitcher
+ * The observed tab switcher as an instance of the class {@link TabSwitcher}. The tab
+ * switcher may not be null
+ * @param selectedTabIndex
+ * The index of the currently selected tab as an {@link Integer} value or -1, if the tab
+ * switcher does not contain any tabs
+ * @param selectedTab
+ * The currently selected tab as an instance of the class {@link Tab} or null, if the
+ * tab switcher does not contain any tabs
+ */
+ void onSelectionChanged(@NonNull TabSwitcher tabSwitcher, int selectedTabIndex,
+ @Nullable Tab selectedTab);
+
+ /**
+ * The method, which is invoked, when a tab has been added to the tab switcher.
+ *
+ * @param tabSwitcher
+ * The observed tab switcher as an instance of the class {@link TabSwitcher}. The tab
+ * switcher may not be null
+ * @param index
+ * The index of the tab, which has been added, as an {@link Integer} value
+ * @param tab
+ * The tab, which has been added, as an instance of the class {@link Tab}. The tab may
+ * not be null
+ * @param animation
+ * The animation, which has been used to add the tab, as an instance of the class {@link
+ * Animation}. The animation may not be null
+ */
+ void onTabAdded(@NonNull TabSwitcher tabSwitcher, int index, @NonNull Tab tab,
+ @NonNull Animation animation);
+
+ /**
+ * The method, which is invoked, when a tab has been removed from the tab switcher.
+ *
+ * @param tabSwitcher
+ * The observed tab switcher as an instance of the class {@link TabSwitcher}. The tab
+ * switcher may not be null
+ * @param index
+ * The index of the tab, which has been removed, as an {@link Integer} value
+ * @param tab
+ * The tab, which has been removed, as an instance of the class {@link Tab}. The tab may
+ * not be null
+ * @param animation
+ * The animation, which has been used to remove the tab, as an instance of the class
+ * {@link Animation}. The animation may not be null
+ */
+ void onTabRemoved(@NonNull TabSwitcher tabSwitcher, int index, @NonNull Tab tab,
+ @NonNull Animation animation);
+
+ /**
+ * The method, which is invoked, when all tabs have been removed from the tab switcher.
+ *
+ * @param tabSwitcher
+ * The observed tab switcher as an instance of the class {@link TabSwitcher}. The tab
+ * switcher may not be null
+ * @param tabs
+ * An array, which contains the tabs, which have been removed, as an array of the type
+ * {@link Tab} or an empty array, if no tabs have been removed
+ * @param animation
+ * The animation, which has been used to remove the tabs, as an instance of the class
+ * {@link Animation}. The animation may not be null
+ */
+ void onAllTabsRemoved(@NonNull TabSwitcher tabSwitcher, @NonNull Tab[] tabs,
+ @NonNull Animation animation);
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/drawable/TabSwitcherDrawable.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/drawable/TabSwitcherDrawable.java
new file mode 100755
index 0000000..cc8f87c
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/drawable/TabSwitcherDrawable.java
@@ -0,0 +1,211 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.drawable;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.Paint.Align;
+import android.graphics.PixelFormat;
+import android.graphics.PorterDuff;
+import android.graphics.Typeface;
+import android.graphics.drawable.Drawable;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v4.content.ContextCompat;
+
+import de.mrapp.android.tabswitcher.Animation;
+import de.mrapp.android.tabswitcher.R;
+import de.mrapp.android.tabswitcher.Tab;
+import de.mrapp.android.tabswitcher.TabSwitcher;
+import de.mrapp.android.tabswitcher.TabSwitcherListener;
+import de.mrapp.android.util.ThemeUtil;
+
+import static de.mrapp.android.util.Condition.ensureAtLeast;
+import static de.mrapp.android.util.Condition.ensureNotNull;
+
+/**
+ * A drawable, which allows to display the number of tabs, which are currently contained by a {@link
+ * TabSwitcher}. It must be registered at a {@link TabSwitcher} instance in order to keep the
+ * displayed label up to date. It therefore implements the interface {@link TabSwitcherListener}.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class TabSwitcherDrawable extends Drawable implements TabSwitcherListener {
+
+ /**
+ * The size of the drawable in pixels.
+ */
+ private final int size;
+
+ /**
+ * The default text size of the displayed label in pixels.
+ */
+ private final int textSizeNormal;
+
+ /**
+ * The text size of the displayed label, which is used when displaying a value greater than 99,
+ * in pixels.
+ */
+ private final int textSizeSmall;
+
+ /**
+ * The drawable, which is shown as the background.
+ */
+ private final Drawable background;
+
+ /**
+ * The paint, which is used to draw the drawable's label.
+ */
+ private final Paint paint;
+
+ /**
+ * The currently displayed label.
+ */
+ private String label;
+
+ /**
+ * Creates a new drawable, which allows to display the number of tabs, which are currently
+ * contained by a {@link TabSwitcher}.
+ *
+ * @param context
+ * The context, which should be used by the drawable, as an instance of the class {@link
+ * Context}. The context may not be null
+ */
+ public TabSwitcherDrawable(@NonNull final Context context) {
+ ensureNotNull(context, "The context may not be null");
+ Resources resources = context.getResources();
+ size = resources.getDimensionPixelSize(R.dimen.tab_switcher_drawable_size);
+ textSizeNormal =
+ resources.getDimensionPixelSize(R.dimen.tab_switcher_drawable_font_size_normal);
+ textSizeSmall =
+ resources.getDimensionPixelSize(R.dimen.tab_switcher_drawable_font_size_small);
+ background =
+ ContextCompat.getDrawable(context, R.drawable.tab_switcher_drawable_background)
+ .mutate();
+ paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ paint.setColor(Color.WHITE);
+ paint.setTextAlign(Align.CENTER);
+ paint.setTextSize(textSizeNormal);
+ paint.setTypeface(Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD));
+ label = Integer.toString(0);
+ int tint = ThemeUtil.getColor(context, android.R.attr.textColorPrimary);
+ setColorFilter(tint, PorterDuff.Mode.MULTIPLY);
+ }
+
+ /**
+ * Updates the drawable to display a specific value.
+ *
+ * @param count
+ * The value, which should be displayed, as an {@link Integer} value. The value must be
+ * at least 0
+ */
+ public final void setCount(final int count) {
+ ensureAtLeast(count, 0, "The count must be at least 0");
+ label = Integer.toString(count);
+
+ if (label.length() > 2) {
+ label = "99+";
+ paint.setTextSize(textSizeSmall);
+ } else {
+ paint.setTextSize(textSizeNormal);
+ }
+
+ invalidateSelf();
+ }
+
+ @Override
+ public final void draw(@NonNull final Canvas canvas) {
+ int width = canvas.getWidth();
+ int height = canvas.getHeight();
+ int intrinsicWidth = background.getIntrinsicWidth();
+ int intrinsicHeight = background.getIntrinsicHeight();
+ int left = (width / 2) - (intrinsicWidth / 2);
+ int top = (height / 2) - (intrinsicHeight / 2);
+ background.getIntrinsicWidth();
+ background.setBounds(left, top, left + intrinsicWidth, top + intrinsicHeight);
+ background.draw(canvas);
+ float x = width / 2f;
+ float y = (height / 2f) - ((paint.descent() + paint.ascent()) / 2f);
+ canvas.drawText(label, x, y, paint);
+ }
+
+ @Override
+ public final int getIntrinsicWidth() {
+ return size;
+ }
+
+ @Override
+ public final int getIntrinsicHeight() {
+ return size;
+ }
+
+ @Override
+ public final void setAlpha(final int alpha) {
+ background.setAlpha(alpha);
+ paint.setAlpha(alpha);
+ }
+
+ @Override
+ public final void setColorFilter(@Nullable final ColorFilter colorFilter) {
+ background.setColorFilter(colorFilter);
+ paint.setColorFilter(colorFilter);
+ }
+
+ @Override
+ public final int getOpacity() {
+ return PixelFormat.TRANSLUCENT;
+ }
+
+ @Override
+ public final void onSwitcherShown(@NonNull final TabSwitcher tabSwitcher) {
+
+ }
+
+ @Override
+ public final void onSwitcherHidden(@NonNull final TabSwitcher tabSwitcher) {
+
+ }
+
+ @Override
+ public final void onSelectionChanged(@NonNull final TabSwitcher tabSwitcher,
+ final int selectedTabIndex,
+ @Nullable final Tab selectedTab) {
+
+ }
+
+ @Override
+ public final void onTabAdded(@NonNull final TabSwitcher tabSwitcher, final int index,
+ @NonNull final Tab tab, @NonNull final Animation animation) {
+ setCount(tabSwitcher.getCount());
+ }
+
+ @Override
+ public final void onTabRemoved(@NonNull final TabSwitcher tabSwitcher, final int index,
+ @NonNull final Tab tab, @NonNull final Animation animation) {
+ setCount(tabSwitcher.getCount());
+ }
+
+ @Override
+ public final void onAllTabsRemoved(@NonNull final TabSwitcher tabSwitcher,
+ @NonNull final Tab[] tab,
+ @NonNull final Animation animation) {
+ setCount(tabSwitcher.getCount());
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/AbstractTabItemIterator.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/AbstractTabItemIterator.java
new file mode 100755
index 0000000..800d75f
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/AbstractTabItemIterator.java
@@ -0,0 +1,232 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.iterator;
+
+import android.support.annotation.NonNull;
+
+import de.mrapp.android.tabswitcher.model.TabItem;
+
+import static de.mrapp.android.util.Condition.ensureAtLeast;
+
+/**
+ * An abstract base class for all iterators, which allow to iterate items of the type {@link
+ * TabItem}.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public abstract class AbstractTabItemIterator implements java.util.Iterator {
+
+ /**
+ * An abstract base class of all builders, which allows to configure and create instances of the
+ * class {@link AbstractTabItemIterator}.
+ */
+ public static abstract class AbstractBuilder, ProductType extends AbstractTabItemIterator> {
+
+ /**
+ * True, if the tabs should be iterated in reverse order, false otherwise.
+ */
+ protected boolean reverse;
+
+ /**
+ * The index of the first tab, which should be iterated.
+ */
+ protected int start;
+
+ /**
+ * Returns a reference to the builder itself. It is implicitly cast to the generic type
+ * BuilderType.
+ *
+ * @return The builder as an instance of the generic type BuilderType
+ */
+ @SuppressWarnings("unchecked")
+ private BuilderType self() {
+ return (BuilderType) this;
+ }
+
+ /**
+ * Creates a new builder, which allows to configure and create instances of the class {@link
+ * AbstractTabItemIterator}.
+ */
+ protected AbstractBuilder() {
+ reverse(false);
+ start(-1);
+ }
+
+ /**
+ * Creates the iterator, which has been configured by using the builder.
+ *
+ * @return The iterator, which has been created, as an instance of the class {@link
+ * TabItemIterator}. The iterator may not be null
+ */
+ @NonNull
+ public abstract ProductType create();
+
+ /**
+ * Sets, whether the tabs should be iterated in reverse order, or not.
+ *
+ * @param reverse
+ * True, if the tabs should be iterated in reverse order, false otherwise
+ * @return The builder, this method has been called upon, as an instance of the generic type
+ * BuilderType. The builder may not be null
+ */
+ @NonNull
+ public BuilderType reverse(final boolean reverse) {
+ this.reverse = reverse;
+ return self();
+ }
+
+ /**
+ * Sets the index of the first tab, which should be iterated.
+ *
+ * @param start
+ * The index, which should be set, as an {@link Integer} value or -1, if all tabs
+ * should be iterated Builder}. The builder may not be null
+ * @return The builder, this method has been called upon, as an instance of the generic type
+ * BuilderType. The builder may not be null
+ */
+ @NonNull
+ public BuilderType start(final int start) {
+ ensureAtLeast(start, -1, "The start must be at least -1");
+ this.start = start;
+ return self();
+ }
+
+ }
+
+ /**
+ * True, if the tabs should be iterated in reverse order, false otherwise.
+ */
+ private boolean reverse;
+
+ /**
+ * The index of the next tab.
+ */
+ private int index;
+
+ /**
+ * The current tab item.
+ */
+ private TabItem current;
+
+ /**
+ * The previous tab item.
+ */
+ private TabItem previous;
+
+ /**
+ * The first tab item.
+ */
+ private TabItem first;
+
+ /**
+ * The method, which is invoked on subclasses in order to retrieve the total number of available
+ * items.
+ *
+ * @return The total number of available items as an {@link Integer} value
+ */
+ public abstract int getCount();
+
+ /**
+ * The method, which is invoked on subclasses in order to retrieve the item, which corresponds
+ * to a specific index.
+ *
+ * @param index
+ * The index of the item, which should be returned, as an {@link Integer} value
+ * @return The item, which corresponds to the given index, as an instance of the class {@link
+ * TabItem}. The tab item may not be null
+ */
+ @NonNull
+ public abstract TabItem getItem(final int index);
+
+ /**
+ * Initializes the iterator.
+ *
+ * @param reverse
+ * True, if the tabs should be iterated in reverse order, false otherwise
+ * @param start
+ * The index of the first tab, which should be iterated, as an {@link Integer} value or
+ * -1, if all tabs should be iterated
+ */
+ protected final void initialize(final boolean reverse, final int start) {
+ ensureAtLeast(start, -1, "The start must be at least -1");
+ this.reverse = reverse;
+ this.previous = null;
+ this.index = start != -1 ? start : (reverse ? getCount() - 1 : 0);
+ int previousIndex = reverse ? this.index + 1 : this.index - 1;
+
+ if (previousIndex >= 0 && previousIndex < getCount()) {
+ this.current = getItem(previousIndex);
+ } else {
+ this.current = null;
+ }
+ }
+
+ /**
+ * Returns the tab item, which corresponds to the first tab.
+ *
+ * @return The tab item, which corresponds to the first tab, as an instance of the class {@link
+ * TabItem} or null, if no tabs are available
+ */
+ public final TabItem first() {
+ return first;
+ }
+
+ /**
+ * Returns the tab item, which corresponds to the previous tab.
+ *
+ * @return The tab item, which corresponds to the previous tab, as an instance of the class
+ * {@link TabItem} or null, if no previous tab is available
+ */
+ public final TabItem previous() {
+ return previous;
+ }
+
+ /**
+ * Returns the tab item, which corresponds to the next tab.
+ *
+ * @return The tab item, which corresponds to the next tab, as an instance of the class {@link
+ * TabItem} or null, if no next tab is available
+ */
+ public final TabItem peek() {
+ return index >= 0 && index < getCount() ? getItem(index) : null;
+ }
+
+ @Override
+ public final boolean hasNext() {
+ if (reverse) {
+ return index >= 0;
+ } else {
+ return getCount() - index >= 1;
+ }
+ }
+
+ @Override
+ public final TabItem next() {
+ if (hasNext()) {
+ previous = current;
+
+ if (first == null) {
+ first = current;
+ }
+
+ current = getItem(index);
+ index += reverse ? -1 : 1;
+ return current;
+ }
+
+ return null;
+ }
+
+}
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/ArrayTabItemIterator.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/ArrayTabItemIterator.java
new file mode 100755
index 0000000..713f18b
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/ArrayTabItemIterator.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.iterator;
+
+import android.support.annotation.NonNull;
+
+import de.mrapp.android.tabswitcher.Tab;
+import de.mrapp.android.tabswitcher.model.TabItem;
+import de.mrapp.android.util.view.AttachedViewRecycler;
+
+import static de.mrapp.android.util.Condition.ensureNotNull;
+
+/**
+ * An iterator, which allows to iterate the tab items, which correspond to the tabs, which are
+ * contained by an array.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class ArrayTabItemIterator extends AbstractTabItemIterator {
+
+ /**
+ * A builder, which allows to configure an create instances of the class {@link
+ * ArrayTabItemIterator}.
+ */
+ public static class Builder extends AbstractBuilder {
+
+ /**
+ * The view recycler, which allows to inflate the views, which are used to visualize the
+ * tabs, which are iterated by the iterator, which is created by the builder.
+ */
+ private final AttachedViewRecycler viewRecycler;
+
+ /**
+ * The array, which contains the tabs, which are iterated by the iterator, which is created
+ * by the builder.
+ */
+ private final Tab[] array;
+
+ /**
+ * Creates a new builder, which allows to configure and create instances of the class {@link
+ * ArrayTabItemIterator}.
+ *
+ * @param viewRecycler
+ * The view recycler, which allows to inflate the views, which are used to visualize
+ * the tabs, which should be iterated by the iterator, as an instance of the class
+ * AttachedViewRecycler. The view recycler may not be null
+ * @param array
+ * The array, which contains the tabs, which should be iterated by the iterator, as
+ * an array of the type {@link Tab}. The array may not be null
+ */
+ public Builder(@NonNull final AttachedViewRecycler viewRecycler,
+ @NonNull final Tab[] array) {
+ ensureNotNull(viewRecycler, "The view recycler may not be null");
+ ensureNotNull(array, "The array may not be null");
+ this.viewRecycler = viewRecycler;
+ this.array = array;
+ }
+
+ @NonNull
+ @Override
+ public ArrayTabItemIterator create() {
+ return new ArrayTabItemIterator(viewRecycler, array, reverse, start);
+ }
+
+ }
+
+ /**
+ * The view recycler, which allows to inflate the views, which are used to visualize the
+ * iterated tabs.
+ */
+ private final AttachedViewRecycler viewRecycler;
+
+ /**
+ * The array, which contains the tabs, which are iterated by the iterator.
+ */
+ private final Tab[] array;
+
+ /**
+ * Creates a new iterator, which allows to iterate the tab items, whcih correspond to the tabs,
+ * which are contained by an array.
+ *
+ * @param viewRecycler
+ * The view recycler, which allows to inflate the views, which are used to visualize the
+ * iterated tabs, as an instance of the class AttachedViewRecycler. The view recycler
+ * may not be null
+ * @param array
+ * The array, which contains the tabs, which should be iterated by the iterator, as an
+ * array of the type {@link Tab}. The array may not be null
+ * @param reverse
+ * True, if the tabs should be iterated in reverse order, false otherwise
+ * @param start
+ * The index of the first tab, which should be iterated, as an {@link Integer} value or
+ * -1, if all tabs should be iterated
+ */
+ private ArrayTabItemIterator(@NonNull final AttachedViewRecycler viewRecycler,
+ @NonNull final Tab[] array, final boolean reverse,
+ final int start) {
+ ensureNotNull(viewRecycler, "The view recycler may not be null");
+ ensureNotNull(array, "The array may not be null");
+ this.viewRecycler = viewRecycler;
+ this.array = array;
+ initialize(reverse, start);
+ }
+
+ @Override
+ public final int getCount() {
+ return array.length;
+ }
+
+ @NonNull
+ @Override
+ public final TabItem getItem(final int index) {
+ return TabItem.create(viewRecycler, index, array[index]);
+ }
+
+}
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/TabItemIterator.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/TabItemIterator.java
new file mode 100755
index 0000000..a8b5e89
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/iterator/TabItemIterator.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.iterator;
+
+import android.support.annotation.NonNull;
+
+import de.mrapp.android.tabswitcher.TabSwitcher;
+import de.mrapp.android.tabswitcher.model.Model;
+import de.mrapp.android.tabswitcher.model.TabItem;
+import de.mrapp.android.util.view.AttachedViewRecycler;
+
+import static de.mrapp.android.util.Condition.ensureNotNull;
+
+/**
+ * An iterator, which allows to iterate the tab items, which correspond to the tabs of a {@link
+ * TabSwitcher}.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class TabItemIterator extends AbstractTabItemIterator {
+
+ /**
+ * A builder, which allows to configure and create instances of the class {@link
+ * TabItemIterator}.
+ */
+ public static class Builder extends AbstractBuilder {
+
+ /**
+ * The model, which belongs to the tab switcher, whose tabs should be iterated by the
+ * iterator, which is created by the builder.
+ */
+ private final Model model;
+
+ /**
+ * The view recycler, which allows to inflate the views, which are used to visualize the
+ * tabs, which are iterated by the iterator, which is created by the builder.
+ */
+ private final AttachedViewRecycler viewRecycler;
+
+ /**
+ * Creates a new builder, which allows to configure and create instances of the class {@link
+ * TabItemIterator}.
+ *
+ * @param model
+ * The model, which belongs to the tab switcher, whose tabs should be iterated by
+ * the iterator, which is created by the builder, as an instance of the type {@link
+ * Model}. The model may not be null
+ * @param viewRecycler
+ * The view recycler, which allows to inflate the views, which are used to visualize
+ * the tabs, which are iterated by the iterator, which is created by the builder, as
+ * an instance of the class AttachedViewRecycler. The view recycler may not be null
+ */
+ public Builder(@NonNull final Model model,
+ @NonNull final AttachedViewRecycler viewRecycler) {
+ ensureNotNull(model, "The model may not be null");
+ ensureNotNull(viewRecycler, "The view recycler may not be null");
+ this.model = model;
+ this.viewRecycler = viewRecycler;
+ }
+
+ @NonNull
+ @Override
+ public TabItemIterator create() {
+ return new TabItemIterator(model, viewRecycler, reverse, start);
+ }
+
+ }
+
+ /**
+ * The model, which belongs to the tab switcher, whose tabs are iterated.
+ */
+ private final Model model;
+
+ /**
+ * The view recycler, which allows to inflated the views, which are used to visualize the
+ * iterated tabs.
+ */
+ private final AttachedViewRecycler viewRecycler;
+
+ /**
+ * Creates a new iterator, which allows to iterate the tab items, which correspond to the tabs
+ * of a {@link TabSwitcher}.
+ *
+ * @param model
+ * The model, which belongs to the tab switcher, whose tabs should be iterated, as an
+ * instance of the type {@link Model}. The model may not be null
+ * @param viewRecycler
+ * The view recycler, which allows to inflate the views, which are used to visualize the
+ * iterated tabs, as an instance of the class AttachedViewRecycler. The view recycler
+ * may not be null
+ * @param reverse
+ * True, if the tabs should be iterated in reverse order, false otherwise
+ * @param start
+ * The index of the first tab, which should be iterated, as an {@link Integer} value or
+ * -1, if all tabs should be iterated
+ */
+ private TabItemIterator(@NonNull final Model model,
+ @NonNull final AttachedViewRecycler viewRecycler,
+ final boolean reverse, final int start) {
+ ensureNotNull(model, "The model may not be null");
+ ensureNotNull(viewRecycler, "The view recycler may not be null");
+ this.model = model;
+ this.viewRecycler = viewRecycler;
+ initialize(reverse, start);
+ }
+
+ @Override
+ public final int getCount() {
+ return model.getCount();
+ }
+
+ @NonNull
+ @Override
+ public final TabItem getItem(final int index) {
+ return TabItem.create(model, viewRecycler, index);
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractDragHandler.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractDragHandler.java
new file mode 100755
index 0000000..296e9cf
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractDragHandler.java
@@ -0,0 +1,778 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.layout;
+
+import android.content.res.Resources;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.ViewConfiguration;
+
+import de.mrapp.android.tabswitcher.R;
+import de.mrapp.android.tabswitcher.TabSwitcher;
+import de.mrapp.android.tabswitcher.layout.Arithmetics.Axis;
+import de.mrapp.android.tabswitcher.model.TabItem;
+import de.mrapp.android.util.gesture.DragHelper;
+
+import static de.mrapp.android.util.Condition.ensureNotNull;
+
+/**
+ * An abstract base class for all drag handlers, which allow to calculate the position and state of
+ * tabs on touch events.
+ *
+ * @param
+ * The type of the drag handler's callback
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public abstract class AbstractDragHandler {
+
+ /**
+ * Contains all possible states of dragging gestures, which can be performed on a {@link
+ * TabSwitcher}.
+ */
+ public enum DragState {
+
+ /**
+ * When no dragging gesture is being performed.
+ */
+ NONE,
+
+ /**
+ * When the tabs are dragged towards the start.
+ */
+ DRAG_TO_START,
+
+ /**
+ * When the tabs are dragged towards the end.
+ */
+ DRAG_TO_END,
+
+ /**
+ * When an overshoot at the start is being performed.
+ */
+ OVERSHOOT_START,
+
+ /**
+ * When an overshoot at the end is being performed.
+ */
+ OVERSHOOT_END,
+
+ /**
+ * When a tab is swiped.
+ */
+ SWIPE
+
+ }
+
+ /**
+ * Defines the interface, a class, which should be notified about the events of a drag handler,
+ * must implement.
+ */
+ public interface Callback {
+
+ /**
+ * The method, which is invoked in order to calculate the positions of all tabs, depending
+ * on the current drag distance.
+ *
+ * @param dragState
+ * The current drag state as a value of the enum {@link DragState}. The drag state
+ * must either be {@link DragState#DRAG_TO_END} or {@link DragState#DRAG_TO_START}
+ * @param dragDistance
+ * The current drag distance in pixels as a {@link Float} value
+ * @return A drag state, which specifies whether the tabs are overshooting, or not. If the
+ * tabs are overshooting, the drag state must be {@link DragState#OVERSHOOT_START} or {@link
+ * DragState#OVERSHOOT_END}, null otherwise
+ */
+ @Nullable
+ DragState onDrag(@NonNull DragState dragState, float dragDistance);
+
+ /**
+ * The method, which is invoked, when a tab has been clicked.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, which has been clicked, as an
+ * instance of the class {@link TabItem}. The tab item may not be null
+ */
+ void onClick(@NonNull TabItem tabItem);
+
+ /**
+ * The method, which is invoked, when a fling has been triggered.
+ *
+ * @param distance
+ * The distance of the fling in pixels as a {@link Float} value
+ * @param duration
+ * The duration of the fling in milliseconds as a {@link Long} value
+ */
+ void onFling(float distance, long duration);
+
+ /**
+ * The method, which is invoked, when a fling has been cancelled.
+ */
+ void onCancelFling();
+
+ /**
+ * The method, which is invoked, when an overshoot at the start should be reverted.
+ */
+ void onRevertStartOvershoot();
+
+ /**
+ * The method, which is invoked, when an overshoot at the end should be reverted.
+ */
+ void onRevertEndOvershoot();
+
+ /**
+ * The method, which is invoked, when a tab is swiped.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the swiped tab, as an instance of the class
+ * {@link TabItem}. The tab item may not be null
+ * @param distance
+ * The distance, the tab is swiped by, in pixels as a {@link Float} value
+ */
+ void onSwipe(@NonNull TabItem tabItem, float distance);
+
+ /**
+ * The method, which is invoked, when swiping a tab ended.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the swiped tab, as an instance of the class
+ * {@link TabItem}. The tab item may not be null
+ * @param remove
+ * True, if the tab should be removed, false otherwise
+ * @param velocity
+ * The velocity of the swipe gesture in pixels per second as a {@link Float} value
+ */
+ void onSwipeEnded(@NonNull TabItem tabItem, boolean remove, float velocity);
+
+ }
+
+ /**
+ * The tab switcher, whose tabs' positions and states are calculated by the drag handler.
+ */
+ private final TabSwitcher tabSwitcher;
+
+ /**
+ * The arithmetics, which are used to calculate the positions, size and rotation of tabs.
+ */
+ private final Arithmetics arithmetics;
+
+ /**
+ * True, if tabs can be swiped on the orthogonal axis, false otherwise.
+ */
+ private final boolean swipeEnabled;
+
+ /**
+ * The drag helper, which is used to recognize drag gestures on the dragging axis.
+ */
+ private final DragHelper dragHelper;
+
+ /**
+ * The drag helper, which is used to recognize swipe gestures on the orthogonal axis.
+ */
+ private final DragHelper swipeDragHelper;
+
+ /**
+ * The minimum velocity, which must be reached by a drag gesture to start a fling animation.
+ */
+ private final float minFlingVelocity;
+
+ /**
+ * The velocity, which may be reached by a drag gesture at maximum to start a fling animation.
+ */
+ private final float maxFlingVelocity;
+
+ /**
+ * The velocity, which must be reached by a drag gesture in order to start a swipe animation.
+ */
+ private final float minSwipeVelocity;
+
+ /**
+ * The threshold, which must be reached until tabs are dragged, in pixels.
+ */
+ private int dragThreshold;
+
+ /**
+ * The velocity tracker, which is used to measure the velocity of dragging gestures.
+ */
+ private VelocityTracker velocityTracker;
+
+ /**
+ * The id of the pointer, which has been used to start the current drag gesture.
+ */
+ private int pointerId;
+
+ /**
+ * The currently swiped tab item.
+ */
+ private TabItem swipedTabItem;
+
+ /**
+ * The state of the currently performed drag gesture.
+ */
+ private DragState dragState;
+
+ /**
+ * The distance of the current drag gesture in pixels.
+ */
+ private float dragDistance;
+
+ /**
+ * The drag distance at which the start overshoot begins.
+ */
+ private float startOvershootThreshold;
+
+ /**
+ * The drag distance at which the end overshoot begins.
+ */
+ private float endOvershootThreshold;
+
+ /**
+ * The callback, which is notified about the drag handler's events.
+ */
+ private CallbackType callback;
+
+ /**
+ * Resets the drag handler to its previous state, when a drag gesture has ended.
+ *
+ * @param dragThreshold
+ * The drag threshold, which should be used to recognize drag gestures, in pixels as an
+ * {@link Integer} value
+ */
+ private void resetDragging(final int dragThreshold) {
+ if (this.velocityTracker != null) {
+ this.velocityTracker.recycle();
+ this.velocityTracker = null;
+ }
+
+ this.pointerId = -1;
+ this.dragState = DragState.NONE;
+ this.swipedTabItem = null;
+ this.dragDistance = 0;
+ this.startOvershootThreshold = -Float.MAX_VALUE;
+ this.endOvershootThreshold = Float.MAX_VALUE;
+ this.dragThreshold = dragThreshold;
+ this.dragHelper.reset(dragThreshold);
+ this.swipeDragHelper.reset();
+ }
+
+ /**
+ * Handles, when a drag gesture has been started.
+ *
+ * @param event
+ * The motion event, which started the drag gesture, as an instance of the class {@link
+ * MotionEvent}. The motion event may not be null
+ */
+ private void handleDown(@NonNull final MotionEvent event) {
+ pointerId = event.getPointerId(0);
+
+ if (velocityTracker == null) {
+ velocityTracker = VelocityTracker.obtain();
+ } else {
+ velocityTracker.clear();
+ }
+
+ velocityTracker.addMovement(event);
+ }
+
+ /**
+ * Handles a click.
+ *
+ * @param event
+ * The motion event, which triggered the click, as an instance of the class {@link
+ * MotionEvent}. The motion event may not be null
+ */
+ private void handleClick(@NonNull final MotionEvent event) {
+ TabItem tabItem = getFocusedTab(arithmetics.getPosition(Axis.DRAGGING_AXIS, event));
+
+ if (tabItem != null) {
+ notifyOnClick(tabItem);
+ }
+ }
+
+ /**
+ * Handles a fling gesture.
+ *
+ * @param event
+ * The motion event, which triggered the fling gesture, as an instance of the class
+ * {@link MotionEvent}. The motion event may not be null
+ * @param dragState
+ * The current drag state, which determines the fling direction, as a value of the enum
+ * {@link DragState}. The drag state may not be null
+ */
+ private void handleFling(@NonNull final MotionEvent event, @NonNull final DragState dragState) {
+ int pointerId = event.getPointerId(0);
+ velocityTracker.computeCurrentVelocity(1000, maxFlingVelocity);
+ float flingVelocity = Math.abs(velocityTracker.getYVelocity(pointerId));
+
+ if (flingVelocity > minFlingVelocity) {
+ float flingDistance = 0.25f * flingVelocity;
+
+ if (dragState == DragState.DRAG_TO_START) {
+ flingDistance = -1 * flingDistance;
+ }
+
+ long duration = Math.round(Math.abs(flingDistance) / flingVelocity * 1000);
+ notifyOnFling(flingDistance, duration);
+ }
+ }
+
+ /**
+ * Handles, when the tabs are overshooting.
+ */
+ private void handleOvershoot() {
+ if (!dragHelper.isReset()) {
+ dragHelper.reset(0);
+ dragDistance = 0;
+ }
+ }
+
+ /**
+ * Notifies the callback in order to calculate the positions of all tabs, depending on the
+ * current drag distance.
+ *
+ * @param dragState
+ * The current drag state as a value of the enum {@link DragState}. The drag state must
+ * either be {@link DragState#DRAG_TO_END} or {@link DragState#DRAG_TO_START}
+ * @param dragDistance
+ * The current drag distance in pixels as a {@link Float} value
+ * @return A drag state, which specifies whether the tabs are overshooting, or not. If the tabs
+ * are overshooting, the drag state must be {@link DragState#OVERSHOOT_START} or {@link
+ * DragState#OVERSHOOT_END}, null otherwise
+ */
+ private DragState notifyOnDrag(@NonNull final DragState dragState, final float dragDistance) {
+ if (callback != null) {
+ return callback.onDrag(dragState, dragDistance);
+ }
+
+ return null;
+ }
+
+ /**
+ * Notifies the callback, that a tab has been clicked.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, which has been clicked, as an instance of
+ * the class {@link TabItem}. The tab item may not be null
+ */
+ private void notifyOnClick(@NonNull final TabItem tabItem) {
+ if (callback != null) {
+ callback.onClick(tabItem);
+ }
+ }
+
+ /**
+ * Notifies the callback, that a fling has been triggered.
+ *
+ * @param distance
+ * The distance of the fling in pixels as a {@link Float} value
+ * @param duration
+ * The duration of the fling in milliseconds as a {@link Long} value
+ */
+ private void notifyOnFling(final float distance, final long duration) {
+ if (callback != null) {
+ callback.onFling(distance, duration);
+ }
+ }
+
+ /**
+ * Notifies the callback, that a fling has been cancelled.
+ */
+ private void notifyOnCancelFling() {
+ if (callback != null) {
+ callback.onCancelFling();
+ }
+ }
+
+ /**
+ * Notifies the callback, that an overshoot at the start should be reverted.
+ */
+ private void notifyOnRevertStartOvershoot() {
+ if (callback != null) {
+ callback.onRevertStartOvershoot();
+ }
+ }
+
+ /**
+ * Notifies the callback, that an overshoot at the end should be reverted.
+ */
+ private void notifyOnRevertEndOvershoot() {
+ if (callback != null) {
+ callback.onRevertEndOvershoot();
+ }
+ }
+
+ /**
+ * Notifies the callback, that a tab is swiped.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the swiped tab, as an instance of the class {@link
+ * TabItem}. The tab item may not be null
+ * @param distance
+ * The distance, the tab is swiped by, in pixels as a {@link Float} value
+ */
+ private void notifyOnSwipe(@NonNull final TabItem tabItem, final float distance) {
+ if (callback != null) {
+ callback.onSwipe(tabItem, distance);
+ }
+ }
+
+ /**
+ * Notifies the callback, that swiping a tab ended.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the swiped tab, as an instance of the class {@link
+ * TabItem}. The tab item may not be null
+ * @param remove
+ * True, if the tab should be removed, false otherwise
+ * @param velocity
+ * The velocity of the swipe gesture in pixels per second as a {@link Float} value
+ */
+ private void notifyOnSwipeEnded(@NonNull final TabItem tabItem, final boolean remove,
+ final float velocity) {
+ if (callback != null) {
+ callback.onSwipeEnded(tabItem, remove, velocity);
+ }
+ }
+
+ /**
+ * Returns the tab switcher, whose tabs' positions and states are calculated by the drag
+ * handler.
+ *
+ * @return The tab switcher, whose tabs' positions and states are calculated by the drag
+ * handler, as an instance of the class {@link TabSwitcher}. The tab switcher may not be null
+ */
+ @NonNull
+ protected TabSwitcher getTabSwitcher() {
+ return tabSwitcher;
+ }
+
+ /**
+ * Returns the arithmetics, which are used to calculate the positions, size and rotation of
+ * tabs.
+ *
+ * @return The arithmetics, which are used to calculate the positions, size and rotation of
+ * tabs, as an instance of the type {@link Arithmetics}. The arithmetics may not be null
+ */
+ @NonNull
+ protected Arithmetics getArithmetics() {
+ return arithmetics;
+ }
+
+ /**
+ * Returns the callback, which should be notified about the drag handler's events.
+ *
+ * @return The callback, which should be notified about the drag handler's events, as an
+ * instance of the generic type CallbackType or null, if no callback should be notified
+ */
+ @Nullable
+ protected CallbackType getCallback() {
+ return callback;
+ }
+
+ /**
+ * Creates a new drag handler, which allows to calculate the position and state of tabs on touch
+ * events.
+ *
+ * @param tabSwitcher
+ * The tab switcher, whose tabs' positions and states should be calculated by the drag
+ * handler, as an instance of the class {@link TabSwitcher}. The tab switcher may not be
+ * null
+ * @param arithmetics
+ * The arithmetics, which should be used to calculate the position, size and rotation of
+ * tabs, as an instance of the type {@link Arithmetics}. The arithmetics may not be
+ * null
+ * @param swipeEnabled
+ * True, if tabs can be swiped on the orthogonal axis, false otherwise
+ */
+ public AbstractDragHandler(@NonNull final TabSwitcher tabSwitcher,
+ @NonNull final Arithmetics arithmetics, final boolean swipeEnabled) {
+ ensureNotNull(tabSwitcher, "The tab switcher may not be null");
+ ensureNotNull(arithmetics, "The arithmetics may not be null");
+ this.tabSwitcher = tabSwitcher;
+ this.arithmetics = arithmetics;
+ this.swipeEnabled = swipeEnabled;
+ this.dragHelper = new DragHelper(0);
+ Resources resources = tabSwitcher.getResources();
+ this.swipeDragHelper =
+ new DragHelper(resources.getDimensionPixelSize(R.dimen.swipe_threshold));
+ this.callback = null;
+ ViewConfiguration configuration = ViewConfiguration.get(tabSwitcher.getContext());
+ this.minFlingVelocity = configuration.getScaledMinimumFlingVelocity();
+ this.maxFlingVelocity = configuration.getScaledMaximumFlingVelocity();
+ this.minSwipeVelocity = resources.getDimensionPixelSize(R.dimen.min_swipe_velocity);
+ resetDragging(resources.getDimensionPixelSize(R.dimen.drag_threshold));
+ }
+
+ /**
+ * The method, which is invoked on implementing subclasses in order to retrieve the tab item,
+ * which corresponds to the tab, which is focused when clicking/dragging at a specific position.
+ *
+ * @param position
+ * The position on the dragging axis in pixels as a {@link Float} value
+ * @return The tab item, which corresponds to the focused tab, as an instance of the class
+ * {@link TabItem} or null, if no tab is focused
+ */
+ protected abstract TabItem getFocusedTab(final float position);
+
+ /**
+ * The method, which is invoked on implementing subclasses, when the tabs are overshooting at
+ * the start.
+ *
+ * @param dragPosition
+ * The position of the pointer on the dragging axis in pixels as a {@link Float} value
+ * @param overshootThreshold
+ * The position on the dragging axis, an overshoot at the start currently starts at, in
+ * pixels as a {@link Float} value
+ * @return The updated position on the dragging axis, an overshoot at the start starts at, in
+ * pixels as a {@link Float} value
+ */
+ protected float onOvershootStart(final float dragPosition, final float overshootThreshold) {
+ return overshootThreshold;
+ }
+
+ /**
+ * The method, which is invoked on implementing subclasses, when the tabs are overshooting at
+ * the end.
+ *
+ * @param dragPosition
+ * The position of the pointer on the dragging axis in pixels as a {@link Float} value
+ * @param overshootThreshold
+ * The position on the dragging axis, an overshoot at the end currently starts at, in
+ * pixels as a {@link Float} value
+ * @return The updated position on the dragging axis, an overshoot at the end starts at, in
+ * pixels as a {@link Float} value
+ */
+ protected float onOvershootEnd(final float dragPosition, final float overshootThreshold) {
+ return overshootThreshold;
+ }
+
+ /**
+ * The method, which is invoked on implementing subclasses, when an overshoot has been reverted.
+ */
+ protected void onOvershootReverted() {
+
+ }
+
+ /**
+ * The method, which invoked on implementing subclasses, when the drag handler has been reset.
+ */
+ protected void onReset() {
+
+ }
+
+ /**
+ * Returns, whether the threshold of a swiped tab item, which causes the corresponding tab to be
+ * removed, has been reached, or not.
+ *
+ * @param swipedTabItem
+ * The swiped tab item as an instance of the class {@link TabItem}. The tab item may not
+ * be null
+ * @return True, if the threshold has been reached, false otherwise
+ */
+ protected boolean isSwipeThresholdReached(@NonNull final TabItem swipedTabItem) {
+ return false;
+ }
+
+ /**
+ * Sets the callback, which should be notified about the drag handler's events.
+ *
+ * @param callback
+ * The callback, which should be set, as an instance of the generic type CallbackType or
+ * null, if no callback should be notified
+ */
+ public final void setCallback(@Nullable final CallbackType callback) {
+ this.callback = callback;
+ }
+
+ /**
+ * Handles a touch event.
+ *
+ * @param event
+ * The event, which should be handled, as an instance of the class {@link MotionEvent}.
+ * The event may be not null
+ * @return True, if the event has been handled, false otherwise
+ */
+ public final boolean handleTouchEvent(@NonNull final MotionEvent event) {
+ ensureNotNull(event, "The motion event may not be null");
+
+ if (tabSwitcher.isSwitcherShown() && !tabSwitcher.isEmpty()) {
+ notifyOnCancelFling();
+
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ handleDown(event);
+ return true;
+ case MotionEvent.ACTION_MOVE:
+ if (!tabSwitcher.isAnimationRunning() && event.getPointerId(0) == pointerId) {
+ if (velocityTracker == null) {
+ velocityTracker = VelocityTracker.obtain();
+ }
+
+ velocityTracker.addMovement(event);
+ handleDrag(arithmetics.getPosition(Axis.DRAGGING_AXIS, event),
+ arithmetics.getPosition(Axis.ORTHOGONAL_AXIS, event));
+ } else {
+ handleRelease(null, dragThreshold);
+ handleDown(event);
+ }
+
+ return true;
+ case MotionEvent.ACTION_UP:
+ if (!tabSwitcher.isAnimationRunning() && event.getPointerId(0) == pointerId) {
+ handleRelease(event, dragThreshold);
+ }
+
+ return true;
+ default:
+ break;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Handles drag gestures.
+ *
+ * @param dragPosition
+ * The position of the pointer on the dragging axis in pixels as a {@link Float} value
+ * @param orthogonalPosition
+ * The position of the pointer of the orthogonal axis in pixels as a {@link Float}
+ * value
+ * @return True, if any tabs have been moved, false otherwise
+ */
+ public final boolean handleDrag(final float dragPosition, final float orthogonalPosition) {
+ if (dragPosition <= startOvershootThreshold) {
+ handleOvershoot();
+ dragState = DragState.OVERSHOOT_START;
+ startOvershootThreshold = onOvershootStart(dragPosition, startOvershootThreshold);
+ } else if (dragPosition >= endOvershootThreshold) {
+ handleOvershoot();
+ dragState = DragState.OVERSHOOT_END;
+ endOvershootThreshold = onOvershootEnd(dragPosition, endOvershootThreshold);
+ } else {
+ onOvershootReverted();
+ float previousDistance = dragHelper.isReset() ? 0 : dragHelper.getDragDistance();
+ dragHelper.update(dragPosition);
+
+ if (swipeEnabled) {
+ swipeDragHelper.update(orthogonalPosition);
+
+ if (dragState == DragState.NONE && swipeDragHelper.hasThresholdBeenReached()) {
+ TabItem tabItem = getFocusedTab(dragHelper.getDragStartPosition());
+
+ if (tabItem != null) {
+ dragState = DragState.SWIPE;
+ swipedTabItem = tabItem;
+ }
+ }
+ }
+
+ if (dragState != DragState.SWIPE && dragHelper.hasThresholdBeenReached()) {
+ if (dragState == DragState.OVERSHOOT_START) {
+ dragState = DragState.DRAG_TO_END;
+ } else if (dragState == DragState.OVERSHOOT_END) {
+ dragState = DragState.DRAG_TO_START;
+ } else {
+ float dragDistance = dragHelper.getDragDistance();
+
+ if (dragDistance == 0) {
+ dragState = DragState.NONE;
+ } else {
+ dragState = previousDistance - dragDistance < 0 ? DragState.DRAG_TO_END :
+ DragState.DRAG_TO_START;
+ }
+ }
+ }
+
+ if (dragState == DragState.SWIPE) {
+ notifyOnSwipe(swipedTabItem, swipeDragHelper.getDragDistance());
+ } else if (dragState != DragState.NONE) {
+ float currentDragDistance = dragHelper.getDragDistance();
+ float distance = currentDragDistance - dragDistance;
+ dragDistance = currentDragDistance;
+ DragState overshoot = notifyOnDrag(dragState, distance);
+
+ if (overshoot == DragState.OVERSHOOT_END && (dragState == DragState.DRAG_TO_END ||
+ dragState == DragState.OVERSHOOT_END)) {
+ endOvershootThreshold = dragPosition;
+ dragState = DragState.OVERSHOOT_END;
+ } else if (overshoot == DragState.OVERSHOOT_START &&
+ (dragState == DragState.DRAG_TO_START ||
+ dragState == DragState.OVERSHOOT_START)) {
+ startOvershootThreshold = dragPosition;
+ dragState = DragState.OVERSHOOT_START;
+ }
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Handles, when a drag gesture has been ended.
+ *
+ * @param event
+ * The motion event, which ended the drag gesture, as an instance of the class {@link
+ * MotionEvent} or null, if no fling animation should be triggered
+ * @param dragThreshold
+ * The drag threshold, which should be used to recognize drag gestures, in pixels as an
+ * {@link Integer} value
+ */
+ public final void handleRelease(@Nullable final MotionEvent event, final int dragThreshold) {
+ if (dragState == DragState.SWIPE) {
+ float swipeVelocity = 0;
+
+ if (event != null && velocityTracker != null) {
+ int pointerId = event.getPointerId(0);
+ velocityTracker.computeCurrentVelocity(1000, maxFlingVelocity);
+ swipeVelocity = Math.abs(velocityTracker.getXVelocity(pointerId));
+ }
+
+ boolean remove = swipedTabItem.getTab().isCloseable() &&
+ (swipeVelocity >= minSwipeVelocity || isSwipeThresholdReached(swipedTabItem));
+ notifyOnSwipeEnded(swipedTabItem, remove,
+ swipeVelocity >= minSwipeVelocity ? swipeVelocity : 0);
+ } else if (dragState == DragState.DRAG_TO_START || dragState == DragState.DRAG_TO_END) {
+ if (event != null && velocityTracker != null && dragHelper.hasThresholdBeenReached()) {
+ handleFling(event, dragState);
+ }
+ } else if (dragState == DragState.OVERSHOOT_END) {
+ notifyOnRevertEndOvershoot();
+ } else if (dragState == DragState.OVERSHOOT_START) {
+ notifyOnRevertStartOvershoot();
+ } else if (event != null) {
+ handleClick(event);
+ }
+
+ resetDragging(dragThreshold);
+ }
+
+ /**
+ * Resets the drag handler to its initial state.
+ *
+ * @param dragThreshold
+ * The drag threshold, which should be used to recognize drag gestures, in pixels as an
+ * {@link Integer} value
+ */
+ public final void reset(final int dragThreshold) {
+ resetDragging(dragThreshold);
+ onReset();
+ }
+
+}
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractTabSwitcherLayout.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractTabSwitcherLayout.java
new file mode 100755
index 0000000..c831fa0
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractTabSwitcherLayout.java
@@ -0,0 +1,616 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.layout;
+
+import android.animation.Animator;
+import android.animation.Animator.AnimatorListener;
+import android.animation.AnimatorListenerAdapter;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.support.annotation.CallSuper;
+import android.support.annotation.MenuRes;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v4.util.Pair;
+import android.support.v7.widget.Toolbar;
+import android.support.v7.widget.Toolbar.OnMenuItemClickListener;
+import android.view.Menu;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewTreeObserver.OnGlobalLayoutListener;
+import android.view.animation.Animation;
+import android.view.animation.DecelerateInterpolator;
+import android.view.animation.Transformation;
+
+import de.mrapp.android.tabswitcher.R;
+import de.mrapp.android.tabswitcher.TabSwitcher;
+import de.mrapp.android.tabswitcher.TabSwitcherDecorator;
+import de.mrapp.android.tabswitcher.model.Model;
+import de.mrapp.android.tabswitcher.model.TabItem;
+import de.mrapp.android.tabswitcher.model.TabSwitcherModel;
+import de.mrapp.android.util.ViewUtil;
+import de.mrapp.android.util.logging.Logger;
+
+import static de.mrapp.android.util.Condition.ensureNotNull;
+
+/**
+ * An abstract base class for all layouts, which implement the functionality of a {@link
+ * TabSwitcher}.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public abstract class AbstractTabSwitcherLayout
+ implements TabSwitcherLayout, OnGlobalLayoutListener, Model.Listener,
+ AbstractDragHandler.Callback {
+
+ /**
+ * Defines the interface, a class, which should be notified about the events of a tab switcher
+ * layout, must implement.
+ */
+ public interface Callback {
+
+ /*
+ * The method, which is invoked, when all animations have been ended.
+ */
+ void onAnimationsEnded();
+
+ }
+
+ /**
+ * A layout listener, which unregisters itself from the observed view, when invoked. The
+ * listener allows to encapsulate another listener, which is notified, when the listener is
+ * invoked.
+ */
+ public static class LayoutListenerWrapper implements OnGlobalLayoutListener {
+
+ /**
+ * The observed view.
+ */
+ private final View view;
+
+ /**
+ * The encapsulated listener.
+ */
+ private final OnGlobalLayoutListener listener;
+
+ /**
+ * Creates a new layout listener, which unregisters itself from the observed view, when
+ * invoked.
+ *
+ * @param view
+ * The observed view as an instance of the class {@link View}. The view may not be
+ * null
+ * @param listener
+ * The listener, which should be encapsulated, as an instance of the type {@link
+ * OnGlobalLayoutListener} or null, if no listener should be encapsulated
+ */
+ public LayoutListenerWrapper(@NonNull final View view,
+ @Nullable final OnGlobalLayoutListener listener) {
+ ensureNotNull(view, "The view may not be null");
+ this.view = view;
+ this.listener = listener;
+ }
+
+ @Override
+ public void onGlobalLayout() {
+ ViewUtil.removeOnGlobalLayoutListener(view.getViewTreeObserver(), this);
+
+ if (listener != null) {
+ listener.onGlobalLayout();
+ }
+ }
+
+ }
+
+ /**
+ * A animation listener, which increases the number of running animations, when the observed
+ * animation is started, and decreases the number of accordingly, when the animation is
+ * finished. The listener allows to encapsulate another animation listener, which is notified
+ * when the animation has been started, canceled or ended.
+ */
+ protected class AnimationListenerWrapper extends AnimatorListenerAdapter {
+
+ /**
+ * The encapsulated listener.
+ */
+ private final AnimatorListener listener;
+
+ /**
+ * Decreases the number of running animations and executes the next pending action, if no
+ * running animations remain.
+ */
+ private void endAnimation() {
+ if (--runningAnimations == 0) {
+ notifyOnAnimationsEnded();
+ }
+ }
+
+ /**
+ * Creates a new animation listener, which increases the number of running animations, when
+ * the observed animation is started, and decreases the number of accordingly, when the
+ * animation is finished.
+ *
+ * @param listener
+ * The listener, which should be encapsulated, as an instance of the type {@link
+ * AnimatorListener} or null, if no listener should be encapsulated
+ */
+ public AnimationListenerWrapper(@Nullable final AnimatorListener listener) {
+ this.listener = listener;
+ }
+
+ @Override
+ public void onAnimationStart(final Animator animation) {
+ super.onAnimationStart(animation);
+ runningAnimations++;
+
+ if (listener != null) {
+ listener.onAnimationStart(animation);
+ }
+ }
+
+ @Override
+ public void onAnimationEnd(final Animator animation) {
+ super.onAnimationEnd(animation);
+
+ if (listener != null) {
+ listener.onAnimationEnd(animation);
+ }
+
+ endAnimation();
+ }
+
+ @Override
+ public void onAnimationCancel(final Animator animation) {
+ super.onAnimationCancel(animation);
+
+ if (listener != null) {
+ listener.onAnimationCancel(animation);
+ }
+
+ endAnimation();
+ }
+
+ }
+
+ /**
+ * An animation, which allows to fling the tabs.
+ */
+ private class FlingAnimation extends android.view.animation.Animation {
+
+ /**
+ * The distance, the tabs should be moved.
+ */
+ private final float distance;
+
+ /**
+ * Creates a new fling animation.
+ *
+ * @param distance
+ * The distance, the tabs should be moved, in pixels as a {@link Float} value
+ */
+ FlingAnimation(final float distance) {
+ this.distance = distance;
+ }
+
+ @Override
+ protected void applyTransformation(final float interpolatedTime, final Transformation t) {
+ if (flingAnimation != null) {
+ dragHandler.handleDrag(distance * interpolatedTime, 0);
+ }
+ }
+
+ }
+
+ /**
+ * The tab switcher, the layout belongs to.
+ */
+ private final TabSwitcher tabSwitcher;
+
+ /**
+ * The model of the tab switcher, the layout belongs to.
+ */
+ private final TabSwitcherModel model;
+
+ /**
+ * The arithmetics, which are used by the layout.
+ */
+ private final Arithmetics arithmetics;
+
+ /**
+ * The threshold, which must be reached until tabs are dragged, in pixels.
+ */
+ private final int dragThreshold;
+
+ /**
+ * The logger, which is used for logging.
+ */
+ private final Logger logger;
+
+ /**
+ * The callback, which is notified about the layout's events.
+ */
+ private Callback callback;
+
+ /**
+ * The number of animations, which are currently running.
+ */
+ private int runningAnimations;
+
+ /**
+ * The animation, which is used to fling the tabs.
+ */
+ private android.view.animation.Animation flingAnimation;
+
+ /**
+ * The drag handler, which is used by the layout.
+ */
+ private AbstractDragHandler> dragHandler;
+
+ /**
+ * Adapts the visibility of the toolbars, which are shown, when the tab switcher is shown.
+ */
+ private void adaptToolbarVisibility() {
+ Toolbar[] toolbars = getToolbars();
+
+ if (toolbars != null) {
+ for (Toolbar toolbar : toolbars) {
+ toolbar.setVisibility(
+ getModel().areToolbarsShown() ? View.VISIBLE : View.INVISIBLE);
+ }
+ }
+ }
+
+ /**
+ * Adapts the title of the toolbar, which is shown, when the tab switcher is shown.
+ */
+ private void adaptToolbarTitle() {
+ Toolbar[] toolbars = getToolbars();
+
+ if (toolbars != null) {
+ toolbars[0].setTitle(getModel().getToolbarTitle());
+ }
+ }
+
+ /**
+ * Adapts the navigation icon of the toolbar, which is shown, when the tab switcher is shown.
+ */
+ private void adaptToolbarNavigationIcon() {
+ Toolbar[] toolbars = getToolbars();
+
+ if (toolbars != null) {
+ Toolbar toolbar = toolbars[0];
+ toolbar.setNavigationIcon(getModel().getToolbarNavigationIcon());
+ toolbar.setNavigationOnClickListener(getModel().getToolbarNavigationIconListener());
+ }
+ }
+
+ /**
+ * Inflates the menu of the toolbar, which is shown, when the tab switcher is shown.
+ */
+ private void inflateToolbarMenu() {
+ Toolbar[] toolbars = getToolbars();
+ int menuId = getModel().getToolbarMenuId();
+
+ if (toolbars != null && menuId != -1) {
+ Toolbar toolbar = toolbars.length > 1 ? toolbars[1] : toolbars[0];
+ toolbar.inflateMenu(menuId);
+ toolbar.setOnMenuItemClickListener(getModel().getToolbarMenuItemListener());
+ }
+ }
+
+ /**
+ * Creates and returns an animation listener, which allows to handle, when a fling animation
+ * ended.
+ *
+ * @return The listener, which has been created, as an instance of the class {@link
+ * Animation.AnimationListener}. The listener may not be null
+ */
+ @NonNull
+ private Animation.AnimationListener createFlingAnimationListener() {
+ return new Animation.AnimationListener() {
+
+ @Override
+ public void onAnimationStart(final android.view.animation.Animation animation) {
+
+ }
+
+ @Override
+ public void onAnimationEnd(final android.view.animation.Animation animation) {
+ dragHandler.handleRelease(null, dragThreshold);
+ flingAnimation = null;
+ notifyOnAnimationsEnded();
+ }
+
+ @Override
+ public void onAnimationRepeat(final android.view.animation.Animation animation) {
+
+ }
+
+ };
+ }
+
+ /**
+ * Notifies the callback, that all animations have been ended.
+ */
+ private void notifyOnAnimationsEnded() {
+ if (callback != null) {
+ callback.onAnimationsEnded();
+ }
+ }
+
+ /**
+ * Returns the tab switcher, the layout belongs to.
+ *
+ * @return The tab switcher, the layout belongs to, as an instance of the class {@link
+ * TabSwitcher}. The tab switcher may not be null
+ */
+ @NonNull
+ protected final TabSwitcher getTabSwitcher() {
+ return tabSwitcher;
+ }
+
+ /**
+ * Returns the model of the tab switcher, the layout belongs to.
+ *
+ * @return The model of the tab switcher, the layout belongs to, as an instance of the class
+ * {@link TabSwitcherModel}. The model may not be null
+ */
+ @NonNull
+ protected final TabSwitcherModel getModel() {
+ return model;
+ }
+
+ /**
+ * Returns the arithmetics, which are used by the layout.
+ *
+ * @return The arithmetics, which are used by the layout, as an instance of the type {@link
+ * Arithmetics}. The arithmetics may not be null
+ */
+ @NonNull
+ protected final Arithmetics getArithmetics() {
+ return arithmetics;
+ }
+
+ /**
+ * Returns the threshold, which must be reached until tabs are dragged.
+ *
+ * @return The threshold, which must be reached until tabs are dragged, in pixels as an {@link
+ * Integer} value
+ */
+ protected final int getDragThreshold() {
+ return dragThreshold;
+ }
+
+ /**
+ * Returns the logger, which is used for logging.
+ *
+ * @return The logger, which is used for logging, as an instance of the class Logger. The logger
+ * may not be null
+ */
+ @NonNull
+ protected final Logger getLogger() {
+ return logger;
+ }
+
+ /**
+ * Returns the context, which is used by the layout.
+ *
+ * @return The context, which is used by the layout, as an instance of the class {@link
+ * Context}. The context may not be null
+ */
+ @NonNull
+ protected final Context getContext() {
+ return tabSwitcher.getContext();
+ }
+
+ /**
+ * Creates a new layout, which implements the functionality of a {@link TabSwitcher}.
+ *
+ * @param tabSwitcher
+ * The tab switcher, the layout belongs to, as an instance of the class {@link
+ * TabSwitcher}. The tab switcher may not be null
+ * @param model
+ * The model of the tab switcher, the layout belongs to, as an instance of the class
+ * {@link TabSwitcherModel}. The model may not be null
+ * @param arithmetics
+ * The arithmetics, which should be used by the layout, as an instance of the type
+ * {@link Arithmetics}. The arithmetics may not be null
+ */
+ public AbstractTabSwitcherLayout(@NonNull final TabSwitcher tabSwitcher,
+ @NonNull final TabSwitcherModel model,
+ @NonNull final Arithmetics arithmetics) {
+ ensureNotNull(tabSwitcher, "The tab switcher may not be null");
+ ensureNotNull(model, "The model may not be null");
+ ensureNotNull(arithmetics, "The arithmetics may not be null");
+ this.tabSwitcher = tabSwitcher;
+ this.model = model;
+ this.arithmetics = arithmetics;
+ this.dragThreshold =
+ getTabSwitcher().getResources().getDimensionPixelSize(R.dimen.drag_threshold);
+ this.logger = new Logger(model.getLogLevel());
+ this.callback = null;
+ this.runningAnimations = 0;
+ this.flingAnimation = null;
+ this.dragHandler = null;
+ }
+
+ /**
+ * The method, which is invoked on implementing subclasses in order to inflate the layout.
+ *
+ * @param tabsOnly
+ * True, if only the tabs should be inflated, false otherwise
+ * @return The drag handler, which is used by the layout, as an instance of the class {@link
+ * AbstractDragHandler} or null, if no drag handler is used
+ */
+ @Nullable
+ protected abstract AbstractDragHandler> onInflateLayout(final boolean tabsOnly);
+
+ /**
+ * The method, which is invoked on implementing subclasses in order to detach the layout.
+ *
+ * @param tabsOnly
+ * True, if only the tabs should be detached, false otherwise
+ * @return A pair, which contains the index of the first visible tab, as well as its current
+ * position, as an instance of the class Pair or null, if the tab switcher is not shown
+ */
+ @Nullable
+ protected abstract Pair onDetachLayout(final boolean tabsOnly);
+
+ /**
+ * Handles a touch event.
+ *
+ * @param event
+ * The touch event as an instance of the class {@link MotionEvent}. The touch event may
+ * not be null
+ * @return True, if the event has been handled, false otherwise
+ */
+ public abstract boolean handleTouchEvent(@NonNull final MotionEvent event);
+
+ /**
+ * Inflates the layout.
+ *
+ * @param tabsOnly
+ * True, if only the tabs should be inflated, false otherwise
+ */
+ public final void inflateLayout(final boolean tabsOnly) {
+ dragHandler = onInflateLayout(tabsOnly);
+
+ if (!tabsOnly) {
+ adaptToolbarVisibility();
+ adaptToolbarTitle();
+ adaptToolbarNavigationIcon();
+ inflateToolbarMenu();
+ }
+ }
+
+ /**
+ * Detaches the layout.
+ *
+ * @param tabsOnly
+ * True, if only the tabs should be detached, false otherwise
+ * @return A pair, which contains the index of the first visible tab, as well as its current
+ * position, as an instance of the class Pair or null, if the tab switcher is not shown
+ */
+ @Nullable
+ public final Pair detachLayout(final boolean tabsOnly) {
+ return onDetachLayout(tabsOnly);
+ }
+
+ /**
+ * Sets the callback, which should be notified about the layout's events.
+ *
+ * @param callback
+ * The callback, which should be set, as an instance of the type {@link Callback} or
+ * null, if no callback should be notified
+ */
+ public final void setCallback(@Nullable final Callback callback) {
+ this.callback = callback;
+ }
+
+ @Override
+ public final boolean isAnimationRunning() {
+ return runningAnimations > 0 || flingAnimation != null;
+ }
+
+ @Nullable
+ @Override
+ public final Menu getToolbarMenu() {
+ Toolbar[] toolbars = getToolbars();
+
+ if (toolbars != null) {
+ Toolbar toolbar = toolbars.length > 1 ? toolbars[1] : toolbars[0];
+ return toolbar.getMenu();
+ }
+
+ return null;
+ }
+
+ @CallSuper
+ @Override
+ public void onDecoratorChanged(@NonNull final TabSwitcherDecorator decorator) {
+ detachLayout(true);
+ onGlobalLayout();
+ }
+
+ @Override
+ public final void onToolbarVisibilityChanged(final boolean visible) {
+ adaptToolbarVisibility();
+ }
+
+ @Override
+ public final void onToolbarTitleChanged(@Nullable final CharSequence title) {
+ adaptToolbarTitle();
+ }
+
+ @Override
+ public final void onToolbarNavigationIconChanged(@Nullable final Drawable icon,
+ @Nullable final OnClickListener listener) {
+ adaptToolbarNavigationIcon();
+ }
+
+ @Override
+ public final void onToolbarMenuInflated(@MenuRes final int resourceId,
+ @Nullable final OnMenuItemClickListener listener) {
+ inflateToolbarMenu();
+ }
+
+ @Override
+ public final void onFling(final float distance, final long duration) {
+ if (dragHandler != null) {
+ flingAnimation = new FlingAnimation(distance);
+ flingAnimation.setFillAfter(true);
+ flingAnimation.setAnimationListener(createFlingAnimationListener());
+ flingAnimation.setDuration(duration);
+ flingAnimation.setInterpolator(new DecelerateInterpolator());
+ getTabSwitcher().startAnimation(flingAnimation);
+ logger.logVerbose(getClass(),
+ "Started fling animation using a distance of " + distance +
+ " pixels and a duration of " + duration + " milliseconds");
+ }
+ }
+
+ @Override
+ public final void onCancelFling() {
+ if (flingAnimation != null) {
+ flingAnimation.cancel();
+ flingAnimation = null;
+ dragHandler.handleRelease(null, dragThreshold);
+ logger.logVerbose(getClass(), "Canceled fling animation");
+ }
+ }
+
+ @Override
+ public void onRevertStartOvershoot() {
+
+ }
+
+ @Override
+ public void onRevertEndOvershoot() {
+
+ }
+
+ @Override
+ public void onSwipe(@NonNull final TabItem tabItem, final float distance) {
+
+ }
+
+ @Override
+ public void onSwipeEnded(@NonNull final TabItem tabItem, final boolean remove,
+ final float velocity) {
+
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractTabViewHolder.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractTabViewHolder.java
new file mode 100755
index 0000000..ef14bc9
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/AbstractTabViewHolder.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.layout;
+
+import android.widget.ImageButton;
+import android.widget.TextView;
+
+import de.mrapp.android.tabswitcher.TabSwitcher;
+
+/**
+ * An abstract base class for all view holders, which allow to store references to the views, a tab
+ * of a {@link TabSwitcher} consists of.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public abstract class AbstractTabViewHolder {
+
+ /**
+ * The text view, which is used to display the title of a tab.
+ */
+ public TextView titleTextView;
+
+ /**
+ * The close button of a tab.
+ */
+ public ImageButton closeButton;
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/Arithmetics.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/Arithmetics.java
new file mode 100755
index 0000000..da12f87
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/Arithmetics.java
@@ -0,0 +1,274 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.layout;
+
+import android.support.annotation.NonNull;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewPropertyAnimator;
+
+import de.mrapp.android.tabswitcher.TabSwitcher;
+import de.mrapp.android.tabswitcher.layout.AbstractDragHandler.DragState;
+
+/**
+ * Defines the interface, a class, which provides methods, which allow to calculate the position,
+ * size and rotation of a {@link TabSwitcher}'s children, must implement.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public interface Arithmetics {
+
+ /**
+ * Contains all axes on which the tabs of a {@link TabSwitcher} can be moved.
+ */
+ enum Axis {
+
+ /**
+ * The axis on which a tab is moved when dragging it.
+ */
+ DRAGGING_AXIS,
+
+ /**
+ * The axis on which a tab is moved, when it is added to or removed from the switcher.
+ */
+ ORTHOGONAL_AXIS,
+
+ /**
+ * The horizontal axis.
+ */
+ X_AXIS,
+
+ /**
+ * The vertical axis.
+ */
+ Y_AXIS
+
+ }
+
+ /**
+ * Returns the position of a motion event on a specific axis.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param event
+ * The motion event, whose position should be returned, as an instance of the class
+ * {@link MotionEvent}. The motion event may not be null
+ * @return The position of the given motion event on the given axis as a {@link Float} value
+ */
+ float getPosition(@NonNull Axis axis, @NonNull MotionEvent event);
+
+ /**
+ * Returns the position of a view on a specific axis.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param view
+ * The view, whose position should be returned, as an instance of the class {@link
+ * View}. The view may not be null
+ * @return The position of the given view on the given axis as a {@link Float} value
+ */
+ float getPosition(@NonNull Axis axis, @NonNull View view);
+
+ /**
+ * Sets the position of a view on a specific axis.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param view
+ * The view, whose position should be set, as an instance of the class {@link View}. The
+ * view may not be null
+ * @param position
+ * The position, which should be set, as a {@link Float} value
+ */
+ void setPosition(@NonNull Axis axis, @NonNull View view, float position);
+
+ /**
+ * Animates the position of a view on a specific axis.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param animator
+ * The animator, which should be used to animate the position, as an instance of the
+ * class {@link ViewPropertyAnimator}. The animator may not be null
+ * @param view
+ * The view, whose position should be animated, as an instance of the class {@link
+ * View}. The view may not be null
+ * @param position
+ * The position, which should be set by the animation, as a {@link Float} value
+ * @param includePadding
+ * True, if the view's padding should be taken into account, false otherwise
+ */
+ void animatePosition(@NonNull Axis axis, @NonNull ViewPropertyAnimator animator,
+ @NonNull View view, float position, boolean includePadding);
+
+ /**
+ * Returns the padding of a view on a specific axis and using a specific gravity.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param gravity
+ * The gravity as an {@link Integer} value. The gravity must be
+ * Gravity.START
or Gravity.END
+ * @param view
+ * The view, whose padding should be returned, as an instance of the class {@link View}.
+ * The view may not be null
+ * @return The padding of the given view on the given axis and using the given gravity as an
+ * {@link Integer} value
+ */
+ int getPadding(@NonNull Axis axis, int gravity, @NonNull View view);
+
+ /**
+ * Returns the scale of a view, depending on its margin.
+ *
+ * @param view
+ * The view, whose scale should be returned, as an instance of the class {@link View}.
+ * The view may not be null
+ * @param includePadding
+ * True, if the view's padding should be taken into account as well, false otherwise
+ * @return The scale of the given view as a {@link Float} value
+ */
+ float getScale(@NonNull final View view, final boolean includePadding);
+
+ /**
+ * Sets the scale of a view on a specific axis.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param view
+ * The view, whose scale should be set, as an instance of the class {@link View}. The
+ * view may not be null
+ * @param scale
+ * The scale, which should be set, as a {@link Float} value
+ */
+ void setScale(@NonNull Axis axis, @NonNull View view, float scale);
+
+ /**
+ * Animates the scale of a view on a specific axis.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param animator
+ * The animator, which should be used to animate the scale, as an instance of the class
+ * {@link ViewPropertyAnimator}. The animator may not be null
+ * @param scale
+ * The scale, which should be set by the animation, as a {@link Float} value
+ */
+ void animateScale(@NonNull Axis axis, @NonNull ViewPropertyAnimator animator, float scale);
+
+ /**
+ * Returns the size of a view on a specific axis.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param view
+ * The view, whose size should be returned, as an instance of the class {@link View}.
+ * The view may not be null
+ * @return The size of the given view on the given axis as a {@link Float} value
+ */
+ float getSize(@NonNull Axis axis, @NonNull View view);
+
+ /**
+ * Returns the size of the container, which contains the tab switcher's tabs, on a specific
+ * axis. By default, the padding and the size of the toolbars are included.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @return The size of the container, which contains the tab switcher's tabs, on the given axis
+ * as a {@link Float} value
+ */
+ float getTabContainerSize(@NonNull Axis axis);
+
+ /**
+ * Returns the size of the container, which contains the tab switcher's tabs, on a specific
+ * axis.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param includePadding
+ * True, if the padding and the size of the toolbars should be included, false
+ * otherwise
+ * @return The size of the container, which contains the tab switcher's tabs, on the given axis
+ * as a {@link Float} value
+ */
+ float getTabContainerSize(@NonNull Axis axis, boolean includePadding);
+
+ /**
+ * Returns the pivot of a view on a specific axis, depending on the current drag state.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param view
+ * The view, whose pivot should be returned, as an instance of the class {@link View}.
+ * The view may not be null
+ * @param dragState
+ * The current drag state as a value of the enum {@link DragState}. The drag state may
+ * not be null
+ * @return The pivot of the given view on the given axis as a {@link Float} value
+ */
+ float getPivot(@NonNull Axis axis, @NonNull View view, @NonNull DragState dragState);
+
+ /**
+ * Sets the pivot of a view on a specific axis.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param view
+ * The view, whose pivot should be set, as an instance of the class {@link View}. The
+ * view may not be null
+ * @param pivot
+ * The pivot, which should be set, as a {@link Float} value
+ */
+ void setPivot(@NonNull Axis axis, @NonNull View view, float pivot);
+
+ /**
+ * Returns the rotation of a view on a specific axis.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param view
+ * The view, whose rotation should be returned, as an instance of the class {@link
+ * View}. The view may not be null
+ * @return The rotation of the given view on the given axis as a {@link Float} value
+ */
+ float getRotation(@NonNull Axis axis, @NonNull View view);
+
+ /**
+ * Sets the rotation of a view on a specific axis.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param view
+ * The view, whose rotation should be set, as an instance of the class {@link View}. The
+ * view may not be null
+ * @param angle
+ * The rotation, which should be set, as a {@link Float} value
+ */
+ void setRotation(@NonNull Axis axis, @NonNull View view, float angle);
+
+ /**
+ * Animates the rotation of a view on a specific axis.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param animator
+ * The animator, should be used to animate the rotation, as an instance of the class
+ * {@link ViewPropertyAnimator}. The animator may not be null
+ * @param angle
+ * The rotation, which should be set by the animation, as a {@link Float} value
+ */
+ void animateRotation(@NonNull Axis axis, @NonNull ViewPropertyAnimator animator, float angle);
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/ChildRecyclerAdapter.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/ChildRecyclerAdapter.java
new file mode 100755
index 0000000..9a6b3f5
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/ChildRecyclerAdapter.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.layout;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.util.SparseArray;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import de.mrapp.android.tabswitcher.Tab;
+import de.mrapp.android.tabswitcher.TabSwitcher;
+import de.mrapp.android.tabswitcher.TabSwitcherDecorator;
+import de.mrapp.android.tabswitcher.model.Restorable;
+import de.mrapp.android.util.view.AbstractViewRecycler;
+
+import static de.mrapp.android.util.Condition.ensureNotNull;
+
+/**
+ * A view recycler adapter, which allows to inflate the views, which are used to visualize the child
+ * views of the tabs of a {@link TabSwitcher}, by encapsulating a {@link TabSwitcherDecorator}.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class ChildRecyclerAdapter extends AbstractViewRecycler.Adapter
+ implements Restorable {
+
+ /**
+ * The name of the extra, which is used to store the saved instance states of previously removed
+ * child views within a bundle.
+ */
+ private static final String SAVED_INSTANCE_STATES_EXTRA =
+ ChildRecyclerAdapter.class.getName() + "::SavedInstanceStates";
+
+ /**
+ * The tab switcher, which contains the tabs, the child views, which are inflated by the
+ * adapter, correspond to.
+ */
+ private final TabSwitcher tabSwitcher;
+
+ /**
+ * The decorator, which is used to inflate the child views.
+ */
+ private final TabSwitcherDecorator decorator;
+
+ /**
+ * A sparse array, which manages the saved instance states of previously removed child views.
+ */
+ private SparseArray savedInstanceStates;
+
+ /**
+ * Creates a new view recycler adapter, which allows to inflate the views, which are used to
+ * visualize the child views of the tabs of a {@link TabSwitcher}, by encapsulating a {@link
+ * TabSwitcherDecorator}.
+ *
+ * @param tabSwitcher
+ * The tab switcher, which contains the tabs, the child views, which are inflated by the
+ * adapter, correspond to, as an instance of the class {@link TabSwitcher}. The tab
+ * switcher may not be null
+ * @param decorator
+ * The decorator, which should be used to inflate the child views, as an instance of the
+ * class {@link TabSwitcherDecorator}. The decorator may not be null
+ */
+ public ChildRecyclerAdapter(@NonNull final TabSwitcher tabSwitcher,
+ @NonNull final TabSwitcherDecorator decorator) {
+ ensureNotNull(tabSwitcher, "The tab switcher may not be null");
+ ensureNotNull(decorator, "The decorator may not be null");
+ this.tabSwitcher = tabSwitcher;
+ this.decorator = decorator;
+ this.savedInstanceStates = new SparseArray<>();
+ }
+
+ @NonNull
+ @Override
+ public final View onInflateView(@NonNull final LayoutInflater inflater,
+ @Nullable final ViewGroup parent, @NonNull final Tab item,
+ final int viewType, @NonNull final Void... params) {
+ int index = tabSwitcher.indexOf(item);
+ return decorator.inflateView(inflater, parent, item, index);
+ }
+
+ @Override
+ public final void onShowView(@NonNull final Context context, @NonNull final View view,
+ @NonNull final Tab item, final boolean inflated,
+ @NonNull final Void... params) {
+ int index = tabSwitcher.indexOf(item);
+ Bundle savedInstanceState = savedInstanceStates.get(item.hashCode());
+ decorator.applyDecorator(context, tabSwitcher, view, item, index, savedInstanceState);
+ }
+
+ @Override
+ public final void onRemoveView(@NonNull final View view, @NonNull final Tab item) {
+ int index = tabSwitcher.indexOf(item);
+ Bundle outState = decorator.saveInstanceState(view, item, index);
+ savedInstanceStates.put(item.hashCode(), outState);
+ }
+
+ @Override
+ public final int getViewTypeCount() {
+ return decorator.getViewTypeCount();
+ }
+
+ @Override
+ public final int getViewType(@NonNull final Tab item) {
+ int index = tabSwitcher.indexOf(item);
+ return decorator.getViewType(item, index);
+ }
+
+ @Override
+ public final void saveInstanceState(@NonNull final Bundle outState) {
+ outState.putSparseParcelableArray(SAVED_INSTANCE_STATES_EXTRA, savedInstanceStates);
+ }
+
+ @Override
+ public final void restoreInstanceState(@Nullable final Bundle savedInstanceState) {
+ if (savedInstanceState != null) {
+ savedInstanceStates =
+ savedInstanceState.getSparseParcelableArray(SAVED_INSTANCE_STATES_EXTRA);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/TabSwitcherLayout.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/TabSwitcherLayout.java
new file mode 100755
index 0000000..ac33396
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/TabSwitcherLayout.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.layout;
+
+import android.support.annotation.Nullable;
+import android.support.v7.widget.Toolbar;
+import android.view.Menu;
+import android.view.ViewGroup;
+
+import de.mrapp.android.tabswitcher.TabSwitcher;
+
+/**
+ * Defines the interface, a layout, which implements the functionality of a {@link TabSwitcher},
+ * must implement.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public interface TabSwitcherLayout {
+
+ /**
+ * Returns, whether an animation is currently running, or not.
+ *
+ * @return True, if an animation is currently running, false otherwise
+ */
+ boolean isAnimationRunning();
+
+ /**
+ * Returns the view group, which contains the tab switcher's tabs.
+ *
+ * @return The view group, which contains the tab switcher's tabs, as an instance of the class
+ * {@link ViewGroup} or null, if the view has not been laid out yet
+ */
+ @Nullable
+ ViewGroup getTabContainer();
+
+ /**
+ * Returns the toolbars, which are shown, when the tab switcher is shown. When using the
+ * smartphone layout, only one toolbar is shown. When using the tablet layout, a primary and
+ * secondary toolbar is shown. In such case, the first index of the returned array corresponds
+ * to the primary toolbar.
+ *
+ * @return An array, which contains the toolbars, which are shown, when the tab switcher is
+ * shown, as an array of the type Toolbar or null, if the view has not been laid out yet
+ */
+ @Nullable
+ Toolbar[] getToolbars();
+
+ /**
+ * Returns the menu of the toolbar, which is shown, when the tab switcher is shown. When using
+ * the tablet layout, the menu corresponds to the secondary toolbar.
+ *
+ * @return The menu of the toolbar as an instance of the type {@link Menu} or null, if the view
+ * has not been laid out yet
+ */
+ @Nullable
+ Menu getToolbarMenu();
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneArithmetics.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneArithmetics.java
new file mode 100755
index 0000000..a7ea2ad
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneArithmetics.java
@@ -0,0 +1,439 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.layout.phone;
+
+import android.content.res.Resources;
+import android.support.annotation.NonNull;
+import android.support.v7.widget.Toolbar;
+import android.view.Gravity;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewPropertyAnimator;
+import android.widget.FrameLayout;
+
+import de.mrapp.android.tabswitcher.Layout;
+import de.mrapp.android.tabswitcher.R;
+import de.mrapp.android.tabswitcher.TabSwitcher;
+import de.mrapp.android.tabswitcher.layout.AbstractDragHandler.DragState;
+import de.mrapp.android.tabswitcher.layout.Arithmetics;
+
+import static de.mrapp.android.util.Condition.ensureNotNull;
+import static de.mrapp.android.util.Condition.ensureTrue;
+
+/**
+ * Provides methods, which allow to calculate the position, size and rotation of a {@link
+ * TabSwitcher}'s children, when using the smartphone layout.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class PhoneArithmetics implements Arithmetics {
+
+ /**
+ * The tab switcher, the arithmetics are calculated for.
+ */
+ private final TabSwitcher tabSwitcher;
+
+ /**
+ * The height of a tab's title container in pixels.
+ */
+ private final int tabTitleContainerHeight;
+
+ /**
+ * The inset of tabs in pixels.
+ */
+ private final int tabInset;
+
+ /**
+ * The number of tabs, which are contained by a stack.
+ */
+ private final int stackedTabCount;
+
+ /**
+ * The space between tabs, which are part of a stack, in pixels.
+ */
+ private final float stackedTabSpacing;
+
+ /**
+ * The pivot when overshooting at the end.
+ */
+ private final float endOvershootPivot;
+
+ /**
+ * Modifies a specific axis depending on the orientation of the tab switcher.
+ *
+ * @param axis
+ * The original axis as a value of the enum {@link Axis}. The axis may not be null
+ * @return The orientation invariant axis as a value of the enum {@link Axis}. The orientation
+ * invariant axis may not be null
+ */
+ @NonNull
+ private Axis getOrientationInvariantAxis(@NonNull final Axis axis) {
+ if (axis == Axis.Y_AXIS) {
+ return Axis.DRAGGING_AXIS;
+ } else if (axis == Axis.X_AXIS) {
+ return Axis.ORTHOGONAL_AXIS;
+ } else if (tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE) {
+ return axis == Axis.DRAGGING_AXIS ? Axis.ORTHOGONAL_AXIS : Axis.DRAGGING_AXIS;
+ } else {
+ return axis;
+ }
+ }
+
+ /**
+ * Returns the default pivot of a view on a specific axis.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param view
+ * The view, whose pivot should be returned, as an instance of the class {@link View}.
+ * The view may not be null
+ * @return The pivot of the given view on the given axis as a {@link Float} value
+ */
+ private float getDefaultPivot(@NonNull final Axis axis, @NonNull final View view) {
+ if (axis == Axis.DRAGGING_AXIS || axis == Axis.Y_AXIS) {
+ return tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE ? getSize(axis, view) / 2f : 0;
+ } else {
+ return tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE ? 0 : getSize(axis, view) / 2f;
+ }
+ }
+
+ /**
+ * Returns the pivot of a view on a specific axis, when it is swiped.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param view
+ * The view, whose pivot should be returned, as an instance of the class {@link View}.
+ * The view may not be null
+ * @return The pivot of the given view on the given axis as a {@link Float} value
+ */
+ private float getPivotWhenSwiping(@NonNull final Axis axis, @NonNull final View view) {
+ if (axis == Axis.DRAGGING_AXIS || axis == Axis.Y_AXIS) {
+ return endOvershootPivot;
+ } else {
+ return getDefaultPivot(axis, view);
+ }
+ }
+
+ /**
+ * Returns the pivot of a view on a specific axis, when overshooting at the start.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param view
+ * The view, whose pivot should be returned, as an instance of the class {@link View}.
+ * The view may not be null
+ * @return The pivot of the given view on the given axis as a {@link Float} value
+ */
+ private float getPivotWhenOvershootingAtStart(@NonNull final Axis axis,
+ @NonNull final View view) {
+ return getSize(axis, view) / 2f;
+ }
+
+ /**
+ * Returns the pivot of a view on a specific axis, when overshooting at the end.
+ *
+ * @param axis
+ * The axis as a value of the enum {@link Axis}. The axis may not be null
+ * @param view
+ * The view, whose pivot should be returned, as an instance of the class {@link View}.
+ * The view may not be null
+ * @return The pivot of the given view on the given axis as a {@link Float} value
+ */
+ private float getPivotWhenOvershootingAtEnd(@NonNull final Axis axis,
+ @NonNull final View view) {
+ if (axis == Axis.DRAGGING_AXIS || axis == Axis.Y_AXIS) {
+ return tabSwitcher.getCount() > 1 ? endOvershootPivot : getSize(axis, view) / 2f;
+ } else {
+ return getSize(axis, view) / 2f;
+ }
+ }
+
+ /**
+ * Creates a new class, which provides methods, which allow to calculate the position, size and
+ * rotation of a {@link TabSwitcher}'s children.
+ *
+ * @param tabSwitcher
+ * The tab switcher, the arithmetics should be calculated for, as an instance of the
+ * class {@link TabSwitcher}. The tab switcher may not be null
+ */
+ public PhoneArithmetics(@NonNull final TabSwitcher tabSwitcher) {
+ ensureNotNull(tabSwitcher, "The tab switcher may not be null");
+ this.tabSwitcher = tabSwitcher;
+ Resources resources = tabSwitcher.getResources();
+ this.tabTitleContainerHeight =
+ resources.getDimensionPixelSize(R.dimen.tab_title_container_height);
+ this.tabInset = resources.getDimensionPixelSize(R.dimen.tab_inset);
+ this.stackedTabCount = resources.getInteger(R.integer.stacked_tab_count);
+ this.stackedTabSpacing = resources.getDimensionPixelSize(R.dimen.stacked_tab_spacing);
+ this.endOvershootPivot = resources.getDimensionPixelSize(R.dimen.end_overshoot_pivot);
+ }
+
+ @Override
+ public final float getPosition(@NonNull final Axis axis, @NonNull final MotionEvent event) {
+ ensureNotNull(axis, "The axis may not be null");
+ ensureNotNull(event, "The motion event may not be null");
+
+ if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) {
+ return event.getY();
+ } else {
+ return event.getX();
+ }
+ }
+
+ @Override
+ public final float getPosition(@NonNull final Axis axis, @NonNull final View view) {
+ ensureNotNull(axis, "The axis may not be null");
+ ensureNotNull(view, "The view may not be null");
+
+ if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) {
+ Toolbar[] toolbars = tabSwitcher.getToolbars();
+ return view.getY() - (tabSwitcher.areToolbarsShown() && tabSwitcher.isSwitcherShown() &&
+ toolbars != null ? toolbars[0].getHeight() - tabInset : 0) -
+ getPadding(axis, Gravity.START, tabSwitcher);
+ } else {
+ FrameLayout.LayoutParams layoutParams =
+ (FrameLayout.LayoutParams) view.getLayoutParams();
+ return view.getX() - layoutParams.leftMargin - tabSwitcher.getPaddingLeft() / 2f +
+ tabSwitcher.getPaddingRight() / 2f +
+ (tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE &&
+ tabSwitcher.isSwitcherShown() ?
+ stackedTabCount * stackedTabSpacing / 2f : 0);
+ }
+ }
+
+ @Override
+ public final void setPosition(@NonNull final Axis axis, @NonNull final View view,
+ final float position) {
+ ensureNotNull(axis, "The axis may not be null");
+ ensureNotNull(view, "The view may not be null");
+
+ if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) {
+ Toolbar[] toolbars = tabSwitcher.getToolbars();
+ view.setY((tabSwitcher.areToolbarsShown() && tabSwitcher.isSwitcherShown() &&
+ toolbars != null ? toolbars[0].getHeight() - tabInset : 0) +
+ getPadding(axis, Gravity.START, tabSwitcher) + position);
+ } else {
+ FrameLayout.LayoutParams layoutParams =
+ (FrameLayout.LayoutParams) view.getLayoutParams();
+ view.setX(position + layoutParams.leftMargin + tabSwitcher.getPaddingLeft() / 2f -
+ tabSwitcher.getPaddingRight() / 2f -
+ (tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE &&
+ tabSwitcher.isSwitcherShown() ?
+ stackedTabCount * stackedTabSpacing / 2f : 0));
+ }
+ }
+
+ @Override
+ public final void animatePosition(@NonNull final Axis axis,
+ @NonNull final ViewPropertyAnimator animator,
+ @NonNull final View view, final float position,
+ final boolean includePadding) {
+ ensureNotNull(axis, "The axis may not be null");
+ ensureNotNull(animator, "The animator may not be null");
+ ensureNotNull(view, "The view may not be null");
+
+ if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) {
+ Toolbar[] toolbars = tabSwitcher.getToolbars();
+ animator.y((tabSwitcher.areToolbarsShown() && tabSwitcher.isSwitcherShown() &&
+ toolbars != null ? toolbars[0].getHeight() - tabInset : 0) +
+ (includePadding ? getPadding(axis, Gravity.START, tabSwitcher) : 0) + position);
+ } else {
+ FrameLayout.LayoutParams layoutParams =
+ (FrameLayout.LayoutParams) view.getLayoutParams();
+ animator.x(position + layoutParams.leftMargin + (includePadding ?
+ tabSwitcher.getPaddingLeft() / 2f - tabSwitcher.getPaddingRight() / 2f : 0) -
+ (tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE &&
+ tabSwitcher.isSwitcherShown() ?
+ stackedTabCount * stackedTabSpacing / 2f : 0));
+ }
+ }
+
+ @Override
+ public final int getPadding(@NonNull final Axis axis, final int gravity,
+ @NonNull final View view) {
+ ensureNotNull(axis, "The axis may not be null");
+ ensureTrue(gravity == Gravity.START || gravity == Gravity.END, "Invalid gravity");
+ ensureNotNull(view, "The view may not be null");
+
+ if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) {
+ return gravity == Gravity.START ? view.getPaddingTop() : view.getPaddingBottom();
+ } else {
+ return gravity == Gravity.START ? view.getPaddingLeft() : view.getPaddingRight();
+ }
+ }
+
+ @Override
+ public final float getScale(@NonNull final View view, final boolean includePadding) {
+ ensureNotNull(view, "The view may not be null");
+ FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams();
+ float width = view.getWidth();
+ float targetWidth = width + layoutParams.leftMargin + layoutParams.rightMargin -
+ (includePadding ? tabSwitcher.getPaddingLeft() + tabSwitcher.getPaddingRight() :
+ 0) - (tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE ?
+ stackedTabCount * stackedTabSpacing : 0);
+ return targetWidth / width;
+ }
+
+ @Override
+ public final void setScale(@NonNull final Axis axis, @NonNull final View view,
+ final float scale) {
+ ensureNotNull(axis, "The axis may not be null");
+ ensureNotNull(view, "The view may not be null");
+
+ if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) {
+ view.setScaleY(scale);
+ } else {
+ view.setScaleX(scale);
+ }
+ }
+
+ @Override
+ public final void animateScale(@NonNull final Axis axis,
+ @NonNull final ViewPropertyAnimator animator,
+ final float scale) {
+ ensureNotNull(axis, "The axis may not be null");
+ ensureNotNull(animator, "The animator may not be null");
+
+ if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) {
+ animator.scaleY(scale);
+ } else {
+ animator.scaleX(scale);
+ }
+ }
+
+ @Override
+ public final float getSize(@NonNull final Axis axis, @NonNull final View view) {
+ ensureNotNull(axis, "The axis may not be null");
+ ensureNotNull(view, "The view may not be null");
+
+ if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) {
+ return view.getHeight() * getScale(view, false);
+ } else {
+ return view.getWidth() * getScale(view, false);
+ }
+ }
+
+ @Override
+ public final float getTabContainerSize(@NonNull final Axis axis) {
+ return getTabContainerSize(axis, true);
+ }
+
+ @Override
+ public final float getTabContainerSize(@NonNull final Axis axis, final boolean includePadding) {
+ ensureNotNull(axis, "The axis may not be null");
+ ViewGroup tabContainer = tabSwitcher.getTabContainer();
+ assert tabContainer != null;
+ FrameLayout.LayoutParams layoutParams =
+ (FrameLayout.LayoutParams) tabContainer.getLayoutParams();
+ int padding = !includePadding ? (getPadding(axis, Gravity.START, tabSwitcher) +
+ getPadding(axis, Gravity.END, tabSwitcher)) : 0;
+ Toolbar[] toolbars = tabSwitcher.getToolbars();
+
+ if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) {
+ int toolbarSize =
+ !includePadding && tabSwitcher.areToolbarsShown() && toolbars != null ?
+ toolbars[0].getHeight() - tabInset : 0;
+ return tabContainer.getHeight() - layoutParams.topMargin - layoutParams.bottomMargin -
+ padding - toolbarSize;
+ } else {
+ return tabContainer.getWidth() - layoutParams.leftMargin - layoutParams.rightMargin -
+ padding;
+ }
+ }
+
+ @Override
+ public final float getPivot(@NonNull final Axis axis, @NonNull final View view,
+ @NonNull final DragState dragState) {
+ ensureNotNull(axis, "The axis may not be null");
+ ensureNotNull(view, "The view may not be null");
+ ensureNotNull(dragState, "The drag state may not be null");
+
+ if (dragState == DragState.SWIPE) {
+ return getPivotWhenSwiping(axis, view);
+ } else if (dragState == DragState.OVERSHOOT_START) {
+ return getPivotWhenOvershootingAtStart(axis, view);
+ } else if (dragState == DragState.OVERSHOOT_END) {
+ return getPivotWhenOvershootingAtEnd(axis, view);
+ } else {
+ return getDefaultPivot(axis, view);
+ }
+ }
+
+ @Override
+ public final void setPivot(@NonNull final Axis axis, @NonNull final View view,
+ final float pivot) {
+ ensureNotNull(axis, "The axis may not be null");
+ ensureNotNull(view, "The view may not be null");
+ FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams();
+
+ if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) {
+ float newPivot = pivot - layoutParams.topMargin - tabTitleContainerHeight;
+ view.setTranslationY(view.getTranslationY() +
+ (view.getPivotY() - newPivot) * (1 - view.getScaleY()));
+ view.setPivotY(newPivot);
+ } else {
+ float newPivot = pivot - layoutParams.leftMargin;
+ view.setTranslationX(view.getTranslationX() +
+ (view.getPivotX() - newPivot) * (1 - view.getScaleX()));
+ view.setPivotX(newPivot);
+ }
+ }
+
+ @Override
+ public final float getRotation(@NonNull final Axis axis, @NonNull final View view) {
+ ensureNotNull(axis, "The axis may not be null");
+ ensureNotNull(view, "The view may not be null");
+
+ if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) {
+ return view.getRotationY();
+ } else {
+ return view.getRotationX();
+ }
+ }
+
+ @Override
+ public final void setRotation(@NonNull final Axis axis, @NonNull final View view,
+ final float angle) {
+ ensureNotNull(axis, "The axis may not be null");
+ ensureNotNull(view, "The view may not be null");
+
+ if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) {
+ view.setRotationY(
+ tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE ? -1 * angle : angle);
+ } else {
+ view.setRotationX(
+ tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE ? -1 * angle : angle);
+ }
+ }
+
+ @Override
+ public final void animateRotation(@NonNull final Axis axis,
+ @NonNull final ViewPropertyAnimator animator,
+ final float angle) {
+ ensureNotNull(axis, "The axis may not be null");
+ ensureNotNull(animator, "The animator may not be null");
+
+ if (getOrientationInvariantAxis(axis) == Axis.DRAGGING_AXIS) {
+ animator.rotationY(
+ tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE ? -1 * angle : angle);
+ } else {
+ animator.rotationX(
+ tabSwitcher.getLayout() == Layout.PHONE_LANDSCAPE ? -1 * angle : angle);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneDragHandler.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneDragHandler.java
new file mode 100755
index 0000000..e4532d6
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneDragHandler.java
@@ -0,0 +1,288 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.layout.phone;
+
+import android.content.res.Resources;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v7.widget.Toolbar;
+import android.view.Gravity;
+import android.view.View;
+
+import de.mrapp.android.tabswitcher.Layout;
+import de.mrapp.android.tabswitcher.R;
+import de.mrapp.android.tabswitcher.TabSwitcher;
+import de.mrapp.android.tabswitcher.iterator.AbstractTabItemIterator;
+import de.mrapp.android.tabswitcher.iterator.TabItemIterator;
+import de.mrapp.android.tabswitcher.layout.AbstractDragHandler;
+import de.mrapp.android.tabswitcher.layout.Arithmetics;
+import de.mrapp.android.tabswitcher.layout.Arithmetics.Axis;
+import de.mrapp.android.tabswitcher.model.State;
+import de.mrapp.android.tabswitcher.model.TabItem;
+import de.mrapp.android.util.gesture.DragHelper;
+import de.mrapp.android.util.view.AttachedViewRecycler;
+
+import static de.mrapp.android.util.Condition.ensureNotNull;
+
+/**
+ * A drag handler, which allows to calculate the position and state of tabs on touch events, when
+ * using the smartphone layout.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class PhoneDragHandler extends AbstractDragHandler {
+
+ /**
+ * Defines the interface, a class, which should be notified about the events of a drag handler,
+ * must implement.
+ */
+ public interface Callback extends AbstractDragHandler.Callback {
+
+ /**
+ * The method, which is invoked, when tabs are overshooting at the start.
+ *
+ * @param position
+ * The position of the first tab in pixels as a {@link Float} value
+ */
+ void onStartOvershoot(float position);
+
+ /**
+ * The method, which is invoked, when the tabs should be tilted when overshooting at the
+ * start.
+ *
+ * @param angle
+ * The angle, the tabs should be tilted by, in degrees as a {@link Float} value
+ */
+ void onTiltOnStartOvershoot(float angle);
+
+ /**
+ * The method, which is invoked, when the tabs should be tilted when overshooting at the
+ * end.
+ *
+ * @param angle
+ * The angle, the tabs should be tilted by, in degrees as a {@link Float} value
+ */
+ void onTiltOnEndOvershoot(float angle);
+
+ }
+
+ /**
+ * The view recycler, which allows to inflate the views, which are used to visualize the tabs,
+ * whose positions and states are calculated by the drag handler.
+ */
+ private final AttachedViewRecycler viewRecycler;
+
+ /**
+ * The drag helper, which is used to recognize drag gestures when overshooting.
+ */
+ private final DragHelper overshootDragHelper;
+
+ /**
+ * The maximum overshoot distance in pixels.
+ */
+ private final int maxOvershootDistance;
+
+ /**
+ * The maximum angle, tabs can be rotated by, when overshooting at the start, in degrees.
+ */
+ private final float maxStartOvershootAngle;
+
+ /**
+ * The maximum angle, tabs can be rotated by, when overshooting at the end, in degrees.
+ */
+ private final float maxEndOvershootAngle;
+
+ /**
+ * The number of tabs, which are contained by a stack.
+ */
+ private final int stackedTabCount;
+
+ /**
+ * The inset of tabs in pixels.
+ */
+ private final int tabInset;
+
+ /**
+ * Notifies the callback, that tabs are overshooting at the start.
+ *
+ * @param position
+ * The position of the first tab in pixels as a {@link Float} value
+ */
+ private void notifyOnStartOvershoot(final float position) {
+ if (getCallback() != null) {
+ getCallback().onStartOvershoot(position);
+ }
+ }
+
+ /**
+ * Notifies the callback, that the tabs should be tilted when overshooting at the start.
+ *
+ * @param angle
+ * The angle, the tabs should be tilted by, in degrees as a {@link Float} value
+ */
+ private void notifyOnTiltOnStartOvershoot(final float angle) {
+ if (getCallback() != null) {
+ getCallback().onTiltOnStartOvershoot(angle);
+ }
+ }
+
+ /**
+ * Notifies the callback, that the tabs should be titled when overshooting at the end.
+ *
+ * @param angle
+ * The angle, the tabs should be tilted by, in degrees as a {@link Float} value
+ */
+ private void notifyOnTiltOnEndOvershoot(final float angle) {
+ if (getCallback() != null) {
+ getCallback().onTiltOnEndOvershoot(angle);
+ }
+ }
+
+ /**
+ * Creates a new drag handler, which allows to calculate the position and state of tabs on touch
+ * events, when using the smartphone layout.
+ *
+ * @param tabSwitcher
+ * The tab switcher, whose tabs' positions and states should be calculated by the drag
+ * handler, as an instance of the class {@link TabSwitcher}. The tab switcher may not be
+ * null
+ * @param arithmetics
+ * The arithmetics, which should be used to calculate the position, size and rotation of
+ * tabs, as an instance of the type {@link Arithmetics}. The arithmetics may not be
+ * null
+ * @param viewRecycler
+ * The view recycler, which allows to inflate the views, which are used to visualize the
+ * tabs, whose positions and states should be calculated by the tab switcher, as an
+ * instance of the class AttachedViewRecycler. The view recycler may not be null
+ */
+ public PhoneDragHandler(@NonNull final TabSwitcher tabSwitcher,
+ @NonNull final Arithmetics arithmetics,
+ @NonNull final AttachedViewRecycler viewRecycler) {
+ super(tabSwitcher, arithmetics, true);
+ ensureNotNull(viewRecycler, "The view recycler may not be null");
+ this.viewRecycler = viewRecycler;
+ this.overshootDragHelper = new DragHelper(0);
+ Resources resources = tabSwitcher.getResources();
+ this.tabInset = resources.getDimensionPixelSize(R.dimen.tab_inset);
+ this.stackedTabCount = resources.getInteger(R.integer.stacked_tab_count);
+ this.maxOvershootDistance = resources.getDimensionPixelSize(R.dimen.max_overshoot_distance);
+ this.maxStartOvershootAngle = resources.getInteger(R.integer.max_start_overshoot_angle);
+ this.maxEndOvershootAngle = resources.getInteger(R.integer.max_end_overshoot_angle);
+ }
+
+ @Override
+ @Nullable
+ protected final TabItem getFocusedTab(final float position) {
+ AbstractTabItemIterator iterator =
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create();
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ if (tabItem.getTag().getState() == State.FLOATING ||
+ tabItem.getTag().getState() == State.STACKED_START_ATOP) {
+ View view = tabItem.getView();
+ Toolbar[] toolbars = getTabSwitcher().getToolbars();
+ float toolbarHeight = getTabSwitcher().getLayout() != Layout.PHONE_LANDSCAPE &&
+ getTabSwitcher().areToolbarsShown() && toolbars != null ?
+ toolbars[0].getHeight() - tabInset : 0;
+ float viewPosition =
+ getArithmetics().getPosition(Axis.DRAGGING_AXIS, view) + toolbarHeight +
+ getArithmetics().getPadding(Axis.DRAGGING_AXIS, Gravity.START,
+ getTabSwitcher());
+
+ if (viewPosition <= position) {
+ return tabItem;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ @Override
+ protected final float onOvershootStart(final float dragPosition,
+ final float overshootThreshold) {
+ float result = overshootThreshold;
+ overshootDragHelper.update(dragPosition);
+ float overshootDistance = overshootDragHelper.getDragDistance();
+
+ if (overshootDistance < 0) {
+ float absOvershootDistance = Math.abs(overshootDistance);
+ float startOvershootDistance =
+ getTabSwitcher().getCount() >= stackedTabCount ? maxOvershootDistance :
+ (getTabSwitcher().getCount() > 1 ? (float) maxOvershootDistance /
+ (float) getTabSwitcher().getCount() : 0);
+
+ if (absOvershootDistance <= startOvershootDistance) {
+ float ratio =
+ Math.max(0, Math.min(1, absOvershootDistance / startOvershootDistance));
+ AbstractTabItemIterator iterator =
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create();
+ TabItem tabItem = iterator.getItem(0);
+ float currentPosition = tabItem.getTag().getPosition();
+ float position = currentPosition - (currentPosition * ratio);
+ notifyOnStartOvershoot(position);
+ } else {
+ float ratio =
+ (absOvershootDistance - startOvershootDistance) / maxOvershootDistance;
+
+ if (ratio >= 1) {
+ overshootDragHelper.setMinDragDistance(overshootDistance);
+ result = dragPosition + maxOvershootDistance + startOvershootDistance;
+ }
+
+ notifyOnTiltOnStartOvershoot(
+ Math.max(0, Math.min(1, ratio)) * maxStartOvershootAngle);
+ }
+ }
+
+ return result;
+ }
+
+ @Override
+ protected final float onOvershootEnd(final float dragPosition, final float overshootThreshold) {
+ float result = overshootThreshold;
+ overshootDragHelper.update(dragPosition);
+ float overshootDistance = overshootDragHelper.getDragDistance();
+ float ratio = overshootDistance / maxOvershootDistance;
+
+ if (ratio >= 1) {
+ overshootDragHelper.setMaxDragDistance(overshootDistance);
+ result = dragPosition - maxOvershootDistance;
+ }
+
+ notifyOnTiltOnEndOvershoot(Math.max(0, Math.min(1, ratio)) *
+ -(getTabSwitcher().getCount() > 1 ? maxEndOvershootAngle : maxStartOvershootAngle));
+ return result;
+ }
+
+ @Override
+ protected final void onOvershootReverted() {
+ overshootDragHelper.reset();
+ }
+
+ @Override
+ protected final void onReset() {
+ overshootDragHelper.reset();
+ }
+
+ @Override
+ protected final boolean isSwipeThresholdReached(@NonNull final TabItem swipedTabItem) {
+ View view = swipedTabItem.getView();
+ return Math.abs(getArithmetics().getPosition(Axis.ORTHOGONAL_AXIS, view)) >
+ getArithmetics().getTabContainerSize(Axis.ORTHOGONAL_AXIS) / 6f;
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneRecyclerAdapter.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneRecyclerAdapter.java
new file mode 100755
index 0000000..7829166
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneRecyclerAdapter.java
@@ -0,0 +1,836 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.layout.phone;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.PorterDuff;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.support.annotation.MenuRes;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v4.content.ContextCompat;
+import android.support.v4.util.Pair;
+import android.support.v7.widget.Toolbar.OnMenuItemClickListener;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.FrameLayout.LayoutParams;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import de.mrapp.android.tabswitcher.Animation;
+import de.mrapp.android.tabswitcher.R;
+import de.mrapp.android.tabswitcher.Tab;
+import de.mrapp.android.tabswitcher.TabCloseListener;
+import de.mrapp.android.tabswitcher.TabPreviewListener;
+import de.mrapp.android.tabswitcher.TabSwitcher;
+import de.mrapp.android.tabswitcher.TabSwitcherDecorator;
+import de.mrapp.android.tabswitcher.iterator.AbstractTabItemIterator;
+import de.mrapp.android.tabswitcher.iterator.TabItemIterator;
+import de.mrapp.android.tabswitcher.model.Model;
+import de.mrapp.android.tabswitcher.model.TabItem;
+import de.mrapp.android.tabswitcher.model.TabSwitcherModel;
+import de.mrapp.android.util.ViewUtil;
+import de.mrapp.android.util.logging.LogLevel;
+import de.mrapp.android.util.multithreading.AbstractDataBinder;
+import de.mrapp.android.util.view.AbstractViewRecycler;
+import de.mrapp.android.util.view.AttachedViewRecycler;
+import de.mrapp.android.util.view.ViewRecycler;
+
+import static de.mrapp.android.util.Condition.ensureNotNull;
+
+/**
+ * A view recycler adapter, which allows to inflate the views, which are used to visualize the tabs
+ * of a {@link TabSwitcher}, when using the smartphone layout.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class PhoneRecyclerAdapter extends AbstractViewRecycler.Adapter
+ implements Tab.Callback, Model.Listener,
+ AbstractDataBinder.Listener {
+
+ /**
+ * The tab switcher, the tabs belong to.
+ */
+ private final TabSwitcher tabSwitcher;
+
+ /**
+ * The model, which belongs to the tab switcher.
+ */
+ private final TabSwitcherModel model;
+
+ /**
+ * The view recycler, which allows to inflate the child views of tabs.
+ */
+ private final ViewRecycler childViewRecycler;
+
+ /**
+ * The data binder, which allows to render previews of tabs.
+ */
+ private final AbstractDataBinder dataBinder;
+
+ /**
+ * The inset of tabs in pixels.
+ */
+ private final int tabInset;
+
+ /**
+ * The width of the border, which is shown around the preview of tabs, in pixels.
+ */
+ private final int tabBorderWidth;
+
+ /**
+ * The height of the view group, which contains a tab's title and close button, in pixels.
+ */
+ private final int tabTitleContainerHeight;
+
+ /**
+ * The default background color of tabs.
+ */
+ private final int tabBackgroundColor;
+
+ /**
+ * The default text color of a tab's title.
+ */
+ private final int tabTitleTextColor;
+
+ /**
+ * The view recycler, the adapter is bound to.
+ */
+ private AttachedViewRecycler viewRecycler;
+
+ /**
+ * Inflates the child view of a tab and adds it to the view hierarchy.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, whose child view should be inflated, as
+ * an instance of the class {@link TabItem}. The tab item may not be null
+ */
+ private void addChildView(@NonNull final TabItem tabItem) {
+ PhoneTabViewHolder viewHolder = tabItem.getViewHolder();
+ View view = viewHolder.child;
+ Tab tab = tabItem.getTab();
+
+ if (view == null) {
+ ViewGroup parent = viewHolder.childContainer;
+ Pair pair = childViewRecycler.inflate(tab, parent);
+ view = pair.first;
+ LayoutParams layoutParams =
+ new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+ layoutParams.setMargins(model.getPaddingLeft(), model.getPaddingTop(),
+ model.getPaddingRight(), model.getPaddingBottom());
+ parent.addView(view, 0, layoutParams);
+ viewHolder.child = view;
+ } else {
+ childViewRecycler.getAdapter().onShowView(model.getContext(), view, tab, false);
+ }
+
+ viewHolder.previewImageView.setVisibility(View.GONE);
+ viewHolder.previewImageView.setImageBitmap(null);
+ viewHolder.borderView.setVisibility(View.GONE);
+ }
+
+ /**
+ * Renders and displays the child view of a tab.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, whose preview should be rendered, as an
+ * instance of the class {@link TabItem}. The tab item may not be null
+ */
+ private void renderChildView(@NonNull final TabItem tabItem) {
+ Tab tab = tabItem.getTab();
+ PhoneTabViewHolder viewHolder = tabItem.getViewHolder();
+ viewHolder.borderView.setVisibility(View.VISIBLE);
+
+ if (viewHolder.child != null) {
+ childViewRecycler.getAdapter().onRemoveView(viewHolder.child, tab);
+ dataBinder.load(tab, viewHolder.previewImageView, false, tabItem);
+ removeChildView(viewHolder, tab);
+ } else {
+ dataBinder.load(tab, viewHolder.previewImageView, tabItem);
+ }
+ }
+
+ /**
+ * Removes the child of a tab from its parent.
+ *
+ * @param viewHolder
+ * The view holder, which stores references to the tab's views, as an instance of the
+ * class {@link PhoneTabViewHolder}. The view holder may not be null
+ * @param tab
+ * The tab, whose child should be removed, as an instance of the class {@link Tab}. The
+ * tab may not be null
+ */
+ private void removeChildView(@NonNull final PhoneTabViewHolder viewHolder,
+ @NonNull final Tab tab) {
+ if (viewHolder.childContainer.getChildCount() > 2) {
+ viewHolder.childContainer.removeViewAt(0);
+ }
+
+ viewHolder.child = null;
+ childViewRecycler.remove(tab);
+ }
+
+ /**
+ * Adapts the log level.
+ */
+ private void adaptLogLevel() {
+ dataBinder.setLogLevel(model.getLogLevel());
+ }
+
+ /**
+ * Adapts the title of a tab.
+ *
+ * @param viewHolder
+ * The view holder, which stores references to the tab's views, as an instance of the
+ * class {@link PhoneTabViewHolder}. The view holder may not be null
+ * @param tab
+ * The tab, whose title should be adapted, as an instance of the class {@link Tab}. The
+ * tab may not be null
+ */
+ private void adaptTitle(@NonNull final PhoneTabViewHolder viewHolder, @NonNull final Tab tab) {
+ viewHolder.titleTextView.setText(tab.getTitle());
+ }
+
+ /**
+ * Adapts the icon of a tab.
+ *
+ * @param viewHolder
+ * The view holder, which stores references to the tab's views, as an instance of the
+ * class {@link PhoneTabViewHolder}. The view holder may not be null
+ * @param tab
+ * The icon, whose icon should be adapted, as an instance of the class {@link Tab}. The
+ * tab may not be null
+ */
+ private void adaptIcon(@NonNull final PhoneTabViewHolder viewHolder, @NonNull final Tab tab) {
+ Drawable icon = tab.getIcon(model.getContext());
+ viewHolder.titleTextView
+ .setCompoundDrawablesWithIntrinsicBounds(icon != null ? icon : model.getTabIcon(),
+ null, null, null);
+ }
+
+ /**
+ * Adapts the visibility of a tab's close button.
+ *
+ * @param viewHolder
+ * The view holder, which stores references to the tab's views, as an instance of the
+ * class {@link PhoneTabViewHolder}. The view holder may not be null
+ * @param tab
+ * The icon, whose close button should be adapted, as an instance of the class {@link
+ * Tab}. The tab may not be null
+ */
+ private void adaptCloseButton(@NonNull final PhoneTabViewHolder viewHolder,
+ @NonNull final Tab tab) {
+ viewHolder.closeButton.setVisibility(tab.isCloseable() ? View.VISIBLE : View.GONE);
+ viewHolder.closeButton.setOnClickListener(
+ tab.isCloseable() ? createCloseButtonClickListener(viewHolder.closeButton, tab) :
+ null);
+ }
+
+ /**
+ * Adapts the icon of a tab's close button.
+ *
+ * @param viewHolder
+ * The view holder, which stores references to the tab's views, as an instance of the
+ * class {@link PhoneTabViewHolder}. The view holder may not be null
+ * @param tab
+ * The icon, whose icon hould be adapted, as an instance of the class {@link Tab}. The
+ * tab may not be null
+ */
+ private void adaptCloseButtonIcon(@NonNull final PhoneTabViewHolder viewHolder,
+ @NonNull final Tab tab) {
+ Drawable icon = tab.getCloseButtonIcon(model.getContext());
+
+ if (icon == null) {
+ icon = model.getTabCloseButtonIcon();
+ }
+
+ if (icon != null) {
+ viewHolder.closeButton.setImageDrawable(icon);
+ } else {
+ viewHolder.closeButton.setImageResource(R.drawable.ic_close_tab_18dp);
+ }
+ }
+
+ /**
+ * Creates and returns a listener, which allows to close a specific tab, when its close button
+ * is clicked.
+ *
+ * @param closeButton
+ * The tab's close button as an instance of the class {@link ImageButton}. The button
+ * may not be null
+ * @param tab
+ * The tab, which should be closed, as an instance of the class {@link Tab}. The tab may
+ * not be null
+ * @return The listener, which has been created, as an instance of the class {@link
+ * OnClickListener}. The listener may not be null
+ */
+ @NonNull
+ private OnClickListener createCloseButtonClickListener(@NonNull final ImageButton closeButton,
+ @NonNull final Tab tab) {
+ return new OnClickListener() {
+
+ @Override
+ public void onClick(final View v) {
+ if (notifyOnCloseTab(tab)) {
+ closeButton.setOnClickListener(null);
+ tabSwitcher.removeTab(tab);
+ }
+ }
+
+ };
+ }
+
+ /**
+ * Notifies all listeners, that a tab is about to be closed by clicking its close button.
+ *
+ * @param tab
+ * The tab, which is about to be closed, as an instance of the class {@link Tab}. The
+ * tab may not be null
+ * @return True, if the tab should be closed, false otherwise
+ */
+ private boolean notifyOnCloseTab(@NonNull final Tab tab) {
+ boolean result = true;
+
+ for (TabCloseListener listener : model.getTabCloseListeners()) {
+ result &= listener.onCloseTab(tabSwitcher, tab);
+ }
+
+ return result;
+ }
+
+ /**
+ * Adapts the background color of a tab.
+ *
+ * @param view
+ * The view, which is used to visualize the tab, as an instance of the class {@link
+ * View}. The view may not be null
+ * @param viewHolder
+ * The view holder, which stores references to the tab's views, as an instance of the
+ * class {@link PhoneTabViewHolder}. The view holder may not be null
+ * @param tab
+ * The tab, whose background color should be adapted, as an instance of the class {@link
+ * Tab}. The tab may not be null
+ */
+ private void adaptBackgroundColor(@NonNull final View view,
+ @NonNull final PhoneTabViewHolder viewHolder,
+ @NonNull final Tab tab) {
+ ColorStateList colorStateList =
+ tab.getBackgroundColor() != null ? tab.getBackgroundColor() :
+ model.getTabBackgroundColor();
+ int color = tabBackgroundColor;
+
+ if (colorStateList != null) {
+ int[] stateSet =
+ model.getSelectedTab() == tab ? new int[]{android.R.attr.state_selected} :
+ new int[]{};
+ color = colorStateList.getColorForState(stateSet, colorStateList.getDefaultColor());
+ }
+
+ Drawable background = view.getBackground();
+ background.setColorFilter(color, PorterDuff.Mode.MULTIPLY);
+ Drawable border = viewHolder.borderView.getBackground();
+ border.setColorFilter(color, PorterDuff.Mode.MULTIPLY);
+ }
+
+ /**
+ * Adapts the text color of a tab's title.
+ *
+ * @param viewHolder
+ * The view holder, which stores references to the tab's views, as an instance of the
+ * class {@link PhoneTabViewHolder}. The view holder may not be null
+ * @param tab
+ * The tab, whose text color should be adapted, as an instance of the class {@link Tab}.
+ * The tab may not be null
+ */
+ private void adaptTitleTextColor(@NonNull final PhoneTabViewHolder viewHolder,
+ @NonNull final Tab tab) {
+ ColorStateList colorStateList = tab.getTitleTextColor() != null ? tab.getTitleTextColor() :
+ model.getTabTitleTextColor();
+
+ if (colorStateList != null) {
+ viewHolder.titleTextView.setTextColor(colorStateList);
+ } else {
+ viewHolder.titleTextView.setTextColor(tabTitleTextColor);
+ }
+ }
+
+ /**
+ * Adapts the selection state of a tab's views.
+ *
+ * @param viewHolder
+ * The view holder, which stores references to the tab's views, as an instance of the
+ * class {@link PhoneTabViewHolder}. The view holder may not be null
+ * @param tab
+ * The tab, whose selection state should be adapted, as an instance of the class {@link
+ * Tab}. The tab may not be null
+ */
+ private void adaptSelectionState(@NonNull final PhoneTabViewHolder viewHolder,
+ @NonNull final Tab tab) {
+ boolean selected = model.getSelectedTab() == tab;
+ viewHolder.titleTextView.setSelected(selected);
+ viewHolder.closeButton.setSelected(selected);
+ }
+
+ /**
+ * Adapts the appearance of all currently inflated tabs, depending on whether they are currently
+ * selected, or not.
+ */
+ private void adaptAllSelectionStates() {
+ AbstractTabItemIterator iterator =
+ new TabItemIterator.Builder(model, viewRecycler).create();
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ if (tabItem.isInflated()) {
+ Tab tab = tabItem.getTab();
+ PhoneTabViewHolder viewHolder = tabItem.getViewHolder();
+ adaptSelectionState(viewHolder, tab);
+ adaptBackgroundColor(tabItem.getView(), viewHolder, tab);
+ }
+ }
+ }
+
+ /**
+ * Adapts the padding of a tab.
+ *
+ * @param viewHolder
+ * The view holder, which stores references to the tab's views, as an instance of the
+ * class {@link PhoneTabViewHolder}. The view holder may not be null
+ */
+ private void adaptPadding(@NonNull final PhoneTabViewHolder viewHolder) {
+ if (viewHolder.child != null) {
+ LayoutParams childLayoutParams = (LayoutParams) viewHolder.child.getLayoutParams();
+ childLayoutParams.setMargins(model.getPaddingLeft(), model.getPaddingTop(),
+ model.getPaddingRight(), model.getPaddingBottom());
+ }
+
+ LayoutParams previewLayoutParams =
+ (LayoutParams) viewHolder.previewImageView.getLayoutParams();
+ previewLayoutParams
+ .setMargins(model.getPaddingLeft(), model.getPaddingTop(), model.getPaddingRight(),
+ model.getPaddingBottom());
+ }
+
+ /**
+ * Returns the tab item, which corresponds to a specific tab.
+ *
+ * @param tab
+ * The tab, whose tab item should be returned, as an instance of the class {@link Tab}.
+ * The tab may not be null
+ * @return The tab item, which corresponds to the given tab, as an instance of the class {@link
+ * TabItem} or null, if no view, which visualizes the tab, is currently inflated
+ */
+ @Nullable
+ private TabItem getTabItem(@NonNull final Tab tab) {
+ ensureNotNull(viewRecycler, "No view recycler has been set", IllegalStateException.class);
+ int index = model.indexOf(tab);
+
+ if (index != -1) {
+ TabItem tabItem = TabItem.create(model, viewRecycler, index);
+
+ if (tabItem.isInflated()) {
+ return tabItem;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Creates a new view recycler adapter, which allows to inflate the views, which are used to
+ * visualize the tabs of a {@link TabSwitcher}.
+ *
+ * @param tabSwitcher
+ * The tab switcher as an instance of the class {@link TabSwitcher}. The tab switcher
+ * may not be null
+ * @param model
+ * The model, which belongs to the tab switcher, as an instance of the class {@link
+ * TabSwitcherModel}. The model may not be null
+ * @param childViewRecycler
+ * The view recycler, which allows to inflate the child views of tabs, as an instance of
+ * the class ViewRecycler. The view recycler may not be null
+ */
+ public PhoneRecyclerAdapter(@NonNull final TabSwitcher tabSwitcher,
+ @NonNull final TabSwitcherModel model,
+ @NonNull final ViewRecycler childViewRecycler) {
+ ensureNotNull(tabSwitcher, "The tab switcher may not be null");
+ ensureNotNull(model, "The model may not be null");
+ ensureNotNull(childViewRecycler, "The child view recycler may not be null");
+ this.tabSwitcher = tabSwitcher;
+ this.model = model;
+ this.childViewRecycler = childViewRecycler;
+ this.dataBinder = new PreviewDataBinder(tabSwitcher, childViewRecycler);
+ this.dataBinder.addListener(this);
+ Resources resources = tabSwitcher.getResources();
+ this.tabInset = resources.getDimensionPixelSize(R.dimen.tab_inset);
+ this.tabBorderWidth = resources.getDimensionPixelSize(R.dimen.tab_border_width);
+ this.tabTitleContainerHeight =
+ resources.getDimensionPixelSize(R.dimen.tab_title_container_height);
+ this.tabBackgroundColor =
+ ContextCompat.getColor(tabSwitcher.getContext(), R.color.tab_background_color);
+ this.tabTitleTextColor =
+ ContextCompat.getColor(tabSwitcher.getContext(), R.color.tab_title_text_color);
+ this.viewRecycler = null;
+ adaptLogLevel();
+ }
+
+ /**
+ * Sets the view recycler, which allows to inflate the views, which are used to visualize tabs.
+ *
+ * @param viewRecycler
+ * The view recycler, which should be set, as an instance of the class
+ * AttachedViewRecycler. The view recycler may not be null
+ */
+ public final void setViewRecycler(
+ @NonNull final AttachedViewRecycler viewRecycler) {
+ ensureNotNull(viewRecycler, "The view recycler may not be null");
+ this.viewRecycler = viewRecycler;
+ }
+
+ /**
+ * Removes all previously rendered previews from the cache.
+ */
+ public final void clearCachedPreviews() {
+ dataBinder.clearCache();
+ }
+
+ @NonNull
+ @Override
+ public final View onInflateView(@NonNull final LayoutInflater inflater,
+ @Nullable final ViewGroup parent,
+ @NonNull final TabItem tabItem, final int viewType,
+ @NonNull final Integer... params) {
+ PhoneTabViewHolder viewHolder = new PhoneTabViewHolder();
+ View view = inflater.inflate(R.layout.phone_tab, tabSwitcher.getTabContainer(), false);
+ Drawable backgroundDrawable =
+ ContextCompat.getDrawable(model.getContext(), R.drawable.phone_tab_background);
+ ViewUtil.setBackground(view, backgroundDrawable);
+ int padding = tabInset + tabBorderWidth;
+ view.setPadding(padding, tabInset, padding, padding);
+ viewHolder.titleContainer = (ViewGroup) view.findViewById(R.id.tab_title_container);
+ viewHolder.titleTextView = (TextView) view.findViewById(R.id.tab_title_text_view);
+ viewHolder.closeButton = (ImageButton) view.findViewById(R.id.close_tab_button);
+ viewHolder.childContainer = (ViewGroup) view.findViewById(R.id.child_container);
+ viewHolder.previewImageView = (ImageView) view.findViewById(R.id.preview_image_view);
+ adaptPadding(viewHolder);
+ viewHolder.borderView = view.findViewById(R.id.border_view);
+ Drawable borderDrawable =
+ ContextCompat.getDrawable(model.getContext(), R.drawable.phone_tab_border);
+ ViewUtil.setBackground(viewHolder.borderView, borderDrawable);
+ view.setTag(R.id.tag_view_holder, viewHolder);
+ tabItem.setView(view);
+ tabItem.setViewHolder(viewHolder);
+ view.setTag(R.id.tag_properties, tabItem.getTag());
+ return view;
+ }
+
+ @Override
+ public final void onShowView(@NonNull final Context context, @NonNull final View view,
+ @NonNull final TabItem tabItem, final boolean inflated,
+ @NonNull final Integer... params) {
+ PhoneTabViewHolder viewHolder = (PhoneTabViewHolder) view.getTag(R.id.tag_view_holder);
+
+ if (!tabItem.isInflated()) {
+ tabItem.setView(view);
+ tabItem.setViewHolder(viewHolder);
+ view.setTag(R.id.tag_properties, tabItem.getTag());
+ }
+
+ LayoutParams layoutParams =
+ new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+ int borderMargin = -(tabInset + tabBorderWidth);
+ int bottomMargin = params.length > 0 && params[0] != -1 ? params[0] : borderMargin;
+ layoutParams.leftMargin = borderMargin;
+ layoutParams.topMargin = -(tabInset + tabTitleContainerHeight);
+ layoutParams.rightMargin = borderMargin;
+ layoutParams.bottomMargin = bottomMargin;
+ view.setLayoutParams(layoutParams);
+ Tab tab = tabItem.getTab();
+ tab.addCallback(this);
+ adaptTitle(viewHolder, tab);
+ adaptIcon(viewHolder, tab);
+ adaptCloseButton(viewHolder, tab);
+ adaptCloseButtonIcon(viewHolder, tab);
+ adaptBackgroundColor(view, viewHolder, tab);
+ adaptTitleTextColor(viewHolder, tab);
+ adaptSelectionState(viewHolder, tab);
+
+ if (!model.isSwitcherShown()) {
+ if (tab == model.getSelectedTab()) {
+ addChildView(tabItem);
+ }
+ } else {
+ renderChildView(tabItem);
+ }
+ }
+
+ @Override
+ public final void onRemoveView(@NonNull final View view, @NonNull final TabItem tabItem) {
+ PhoneTabViewHolder viewHolder = (PhoneTabViewHolder) view.getTag(R.id.tag_view_holder);
+ Tab tab = tabItem.getTab();
+ tab.removeCallback(this);
+ removeChildView(viewHolder, tab);
+
+ if (!dataBinder.isCached(tab)) {
+ Drawable drawable = viewHolder.previewImageView.getDrawable();
+ viewHolder.previewImageView.setImageBitmap(null);
+
+ if (drawable instanceof BitmapDrawable) {
+ Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap();
+
+ if (bitmap != null && !bitmap.isRecycled()) {
+ bitmap.recycle();
+ }
+ }
+ } else {
+ viewHolder.previewImageView.setImageBitmap(null);
+ }
+
+ view.setTag(R.id.tag_properties, null);
+ }
+
+ @Override
+ public final void onTitleChanged(@NonNull final Tab tab) {
+ TabItem tabItem = getTabItem(tab);
+
+ if (tabItem != null) {
+ adaptTitle(tabItem.getViewHolder(), tabItem.getTab());
+ }
+ }
+
+ @Override
+ public final void onIconChanged(@NonNull final Tab tab) {
+ TabItem tabItem = getTabItem(tab);
+
+ if (tabItem != null) {
+ adaptIcon(tabItem.getViewHolder(), tabItem.getTab());
+ }
+ }
+
+ @Override
+ public final void onCloseableChanged(@NonNull final Tab tab) {
+ TabItem tabItem = getTabItem(tab);
+
+ if (tabItem != null) {
+ adaptCloseButton(tabItem.getViewHolder(), tabItem.getTab());
+ }
+ }
+
+ @Override
+ public final void onCloseButtonIconChanged(@NonNull final Tab tab) {
+ TabItem tabItem = getTabItem(tab);
+
+ if (tabItem != null) {
+ adaptCloseButtonIcon(tabItem.getViewHolder(), tabItem.getTab());
+ }
+ }
+
+ @Override
+ public final void onBackgroundColorChanged(@NonNull final Tab tab) {
+ TabItem tabItem = getTabItem(tab);
+
+ if (tabItem != null) {
+ adaptBackgroundColor(tabItem.getView(), tabItem.getViewHolder(), tabItem.getTab());
+ }
+ }
+
+ @Override
+ public final void onTitleTextColorChanged(@NonNull final Tab tab) {
+ TabItem tabItem = getTabItem(tab);
+
+ if (tabItem != null) {
+ adaptTitleTextColor(tabItem.getViewHolder(), tabItem.getTab());
+ }
+ }
+
+ @Override
+ public final void onLogLevelChanged(@NonNull final LogLevel logLevel) {
+ adaptLogLevel();
+ }
+
+ @Override
+ public final void onDecoratorChanged(@NonNull final TabSwitcherDecorator decorator) {
+
+ }
+
+ @Override
+ public final void onSwitcherShown() {
+
+ }
+
+ @Override
+ public final void onSwitcherHidden() {
+
+ }
+
+ @Override
+ public final void onSelectionChanged(final int previousIndex, final int index,
+ @Nullable final Tab selectedTab,
+ final boolean switcherHidden) {
+ adaptAllSelectionStates();
+ }
+
+ @Override
+ public final void onTabAdded(final int index, @NonNull final Tab tab,
+ final int previousSelectedTabIndex, final int selectedTabIndex,
+ final boolean switcherVisibilityChanged,
+ @NonNull final Animation animation) {
+ if (previousSelectedTabIndex != selectedTabIndex) {
+ adaptAllSelectionStates();
+ }
+ }
+
+ @Override
+ public final void onAllTabsAdded(final int index, @NonNull final Tab[] tabs,
+ final int previousSelectedTabIndex, final int selectedTabIndex,
+ @NonNull final Animation animation) {
+ if (previousSelectedTabIndex != selectedTabIndex) {
+ adaptAllSelectionStates();
+ }
+ }
+
+ @Override
+ public final void onTabRemoved(final int index, @NonNull final Tab tab,
+ final int previousSelectedTabIndex, final int selectedTabIndex,
+ @NonNull final Animation animation) {
+ if (previousSelectedTabIndex != selectedTabIndex) {
+ adaptAllSelectionStates();
+ }
+ }
+
+ @Override
+ public final void onAllTabsRemoved(@NonNull final Tab[] tabs,
+ @NonNull final Animation animation) {
+
+ }
+
+ @Override
+ public final void onPaddingChanged(final int left, final int top, final int right,
+ final int bottom) {
+ TabItemIterator iterator = new TabItemIterator.Builder(model, viewRecycler).create();
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ if (tabItem.isInflated()) {
+ adaptPadding(tabItem.getViewHolder());
+ }
+ }
+ }
+
+ @Override
+ public final void onTabIconChanged(@Nullable final Drawable icon) {
+ TabItemIterator iterator = new TabItemIterator.Builder(model, viewRecycler).create();
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ if (tabItem.isInflated()) {
+ adaptIcon(tabItem.getViewHolder(), tabItem.getTab());
+ }
+ }
+ }
+
+ @Override
+ public final void onTabBackgroundColorChanged(@Nullable final ColorStateList colorStateList) {
+ TabItemIterator iterator = new TabItemIterator.Builder(model, viewRecycler).create();
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ if (tabItem.isInflated()) {
+ adaptBackgroundColor(tabItem.getView(), tabItem.getViewHolder(), tabItem.getTab());
+ }
+ }
+ }
+
+ @Override
+ public final void onTabTitleColorChanged(@Nullable final ColorStateList colorStateList) {
+ TabItemIterator iterator = new TabItemIterator.Builder(model, viewRecycler).create();
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ if (tabItem.isInflated()) {
+ adaptTitleTextColor(tabItem.getViewHolder(), tabItem.getTab());
+ }
+ }
+ }
+
+ @Override
+ public final void onTabCloseButtonIconChanged(@Nullable final Drawable icon) {
+ TabItemIterator iterator = new TabItemIterator.Builder(model, viewRecycler).create();
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ if (tabItem.isInflated()) {
+ adaptCloseButtonIcon(tabItem.getViewHolder(), tabItem.getTab());
+ }
+ }
+ }
+
+ @Override
+ public final void onToolbarVisibilityChanged(final boolean visible) {
+
+ }
+
+ @Override
+ public final void onToolbarTitleChanged(@Nullable final CharSequence title) {
+
+ }
+
+ @Override
+ public final void onToolbarNavigationIconChanged(@Nullable final Drawable icon,
+ @Nullable final OnClickListener listener) {
+
+ }
+
+ @Override
+ public final void onToolbarMenuInflated(@MenuRes final int resourceId,
+ @Nullable final OnMenuItemClickListener listener) {
+
+ }
+
+ @Override
+ public final boolean onLoadData(
+ @NonNull final AbstractDataBinder dataBinder,
+ @NonNull final Tab key, @NonNull final TabItem... params) {
+ boolean result = true;
+
+ for (TabPreviewListener listener : model.getTabPreviewListeners()) {
+ result &= listener.onLoadTabPreview(tabSwitcher, key);
+ }
+
+ return result;
+ }
+
+ @Override
+ public final void onFinished(
+ @NonNull final AbstractDataBinder dataBinder,
+ @NonNull final Tab key, @Nullable final Bitmap data, @NonNull final ImageView view,
+ @NonNull final TabItem... params) {
+
+ }
+
+ @Override
+ public final void onCanceled(
+ @NonNull final AbstractDataBinder dataBinder) {
+
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneTabSwitcherLayout.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneTabSwitcherLayout.java
new file mode 100755
index 0000000..b685765
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneTabSwitcherLayout.java
@@ -0,0 +1,3705 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.layout.phone;
+
+import android.animation.Animator;
+import android.animation.Animator.AnimatorListener;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ValueAnimator;
+import android.content.res.ColorStateList;
+import android.content.res.Resources;
+import android.graphics.drawable.Drawable;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v4.util.Pair;
+import android.support.v4.view.ViewCompat;
+import android.support.v7.widget.Toolbar;
+import android.util.TypedValue;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewPropertyAnimator;
+import android.view.ViewTreeObserver.OnGlobalLayoutListener;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import android.view.animation.AccelerateInterpolator;
+import android.view.animation.DecelerateInterpolator;
+import android.view.animation.Interpolator;
+import android.widget.FrameLayout;
+
+import java.util.Collections;
+
+import de.mrapp.android.tabswitcher.Animation;
+import de.mrapp.android.tabswitcher.Layout;
+import de.mrapp.android.tabswitcher.PeekAnimation;
+import de.mrapp.android.tabswitcher.R;
+import de.mrapp.android.tabswitcher.RevealAnimation;
+import de.mrapp.android.tabswitcher.SwipeAnimation;
+import de.mrapp.android.tabswitcher.SwipeAnimation.SwipeDirection;
+import de.mrapp.android.tabswitcher.Tab;
+import de.mrapp.android.tabswitcher.TabSwitcher;
+import de.mrapp.android.tabswitcher.TabSwitcherDecorator;
+import de.mrapp.android.tabswitcher.iterator.AbstractTabItemIterator;
+import de.mrapp.android.tabswitcher.iterator.ArrayTabItemIterator;
+import de.mrapp.android.tabswitcher.iterator.TabItemIterator;
+import de.mrapp.android.tabswitcher.layout.AbstractDragHandler;
+import de.mrapp.android.tabswitcher.layout.AbstractDragHandler.DragState;
+import de.mrapp.android.tabswitcher.layout.AbstractTabSwitcherLayout;
+import de.mrapp.android.tabswitcher.layout.Arithmetics.Axis;
+import de.mrapp.android.tabswitcher.model.State;
+import de.mrapp.android.tabswitcher.model.TabItem;
+import de.mrapp.android.tabswitcher.model.TabSwitcherModel;
+import de.mrapp.android.tabswitcher.model.Tag;
+import de.mrapp.android.util.logging.LogLevel;
+import de.mrapp.android.util.view.AttachedViewRecycler;
+import de.mrapp.android.util.view.ViewRecycler;
+
+import static de.mrapp.android.util.Condition.ensureEqual;
+import static de.mrapp.android.util.Condition.ensureGreater;
+import static de.mrapp.android.util.Condition.ensureNotNull;
+import static de.mrapp.android.util.Condition.ensureTrue;
+
+/**
+ * A layout, which implements the functionality of a {@link TabSwitcher} on smartphones.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class PhoneTabSwitcherLayout extends AbstractTabSwitcherLayout
+ implements PhoneDragHandler.Callback {
+
+ /**
+ * An iterator, which allows to iterate the tab items, which correspond to the tabs of a {@link
+ * TabSwitcher}. When a tab item is referenced for the first time, its initial position and
+ * state is calculated and the tab item is stored in a backing array. When the tab item is
+ * iterated again, it is retrieved from the backing array.
+ */
+ private class InitialTabItemIterator extends AbstractTabItemIterator {
+
+ /**
+ * The backing array, which is used to store tab items, once their initial position and
+ * state has been calculated.
+ */
+ private final TabItem[] array;
+
+ /**
+ * Calculates the initial position and state of a specific tab item.
+ *
+ * @param tabItem
+ * The tab item, whose position and state should be calculated, as an instance of
+ * the class {@link TabItem}. The tab item may not be null
+ * @param predecessor
+ * The predecessor of the given tab item as an instance of the class {@link TabItem}
+ * or null, if the tab item does not have a predecessor
+ */
+ private void calculateAndClipStartPosition(@NonNull final TabItem tabItem,
+ @Nullable final TabItem predecessor) {
+ float position = calculateStartPosition(tabItem);
+ Pair pair =
+ clipTabPosition(getModel().getCount(), tabItem.getIndex(), position,
+ predecessor);
+ tabItem.getTag().setPosition(pair.first);
+ tabItem.getTag().setState(pair.second);
+ }
+
+ /**
+ * Calculates and returns the initial position of a specific tab item.
+ *
+ * @param tabItem
+ * The tab item, whose position should be calculated, as an instance of the class
+ * {@link TabItem}. The tab item may not be null
+ * @return The position, which has been calculated, as a {@link Float} value
+ */
+ private float calculateStartPosition(@NonNull final TabItem tabItem) {
+ if (tabItem.getIndex() == 0) {
+ return getCount() > stackedTabCount ? stackedTabCount * stackedTabSpacing :
+ (getCount() - 1) * stackedTabSpacing;
+
+ } else {
+ return -1;
+ }
+ }
+
+ /**
+ * Creates a new iterator, which allows to iterate the tab items, which corresponds to the
+ * tabs of a {@link TabSwitcher}.
+ *
+ * @param array
+ * The backing array, which should be used to store tab items, once their initial
+ * position and state has been calculated, as an array of the type {@link TabItem}.
+ * The array may not be null and the array's length must be equal to the number of
+ * tabs, which are contained by the given tab switcher
+ * @param reverse
+ * True, if the tabs should be iterated in reverse order, false otherwise
+ * @param start
+ * The index of the first tab, which should be iterated, as an {@link Integer} value
+ * or -1, if all tabs should be iterated
+ */
+ private InitialTabItemIterator(@NonNull final TabItem[] array, final boolean reverse,
+ final int start) {
+ ensureNotNull(array, "The array may not be null");
+ ensureEqual(array.length, getModel().getCount(),
+ "The array's length must be " + getModel().getCount());
+ this.array = array;
+ initialize(reverse, start);
+ }
+
+ @Override
+ public final int getCount() {
+ return array.length;
+ }
+
+ @NonNull
+ @Override
+ public final TabItem getItem(final int index) {
+ TabItem tabItem = array[index];
+
+ if (tabItem == null) {
+ tabItem = TabItem.create(getModel(), viewRecycler, index);
+ calculateAndClipStartPosition(tabItem, index > 0 ? getItem(index - 1) : null);
+ array[index] = tabItem;
+ }
+
+ return tabItem;
+ }
+
+ }
+
+ /**
+ * A layout listener, which encapsulates another listener, which is notified, when the listener
+ * has been invoked a specific number of times.
+ */
+ private class CompoundLayoutListener implements OnGlobalLayoutListener {
+
+ /**
+ * The number of times, the listener must still be invoked, until the encapsulated listener
+ * is notified.
+ */
+ private int count;
+
+ /**
+ * The encapsulated listener;
+ */
+ private final OnGlobalLayoutListener listener;
+
+ /**
+ * Creates a new layout listener, which encapsulates another listener, which is notified,
+ * when the listener has been invoked a specific number of times.
+ *
+ * @param count
+ * The number of times, the listener should be invoked until the encapsulated
+ * listener is notified, as an {@link Integer} value. The count must be greater than
+ * 0
+ * @param listener
+ * The encapsulated listener, which should be notified, when the listener has been
+ * notified the given number of times, as an instance of the type {@link
+ * OnGlobalLayoutListener} or null, if no listener should be notified
+ */
+ CompoundLayoutListener(final int count, @Nullable final OnGlobalLayoutListener listener) {
+ ensureGreater(count, 0, "The count must be greater than 0");
+ this.count = count;
+ this.listener = listener;
+ }
+
+ @Override
+ public void onGlobalLayout() {
+ if (--count == 0) {
+ if (listener != null) {
+ listener.onGlobalLayout();
+ }
+ }
+ }
+
+ }
+
+ /**
+ * The ratio, which specifies the maximum space between the currently selected tab and its
+ * predecessor in relation to the default space.
+ */
+ private static final float SELECTED_TAB_SPACING_RATIO = 1.5f;
+
+ /**
+ * The ratio, which specifies the minimum space between two neighboring tabs in relation to the
+ * maximum space.
+ */
+ private static final float MIN_TAB_SPACING_RATIO = 0.375f;
+
+ /**
+ * The inset of tabs in pixels.
+ */
+ private final int tabInset;
+
+ /**
+ * The width of the border, which is drawn around the preview of tabs.
+ */
+ private final int tabBorderWidth;
+
+ /**
+ * The height of a tab's title container in pixels.
+ */
+ private final int tabTitleContainerHeight;
+
+ /**
+ * The number of tabs, which are contained by a stack.
+ */
+ private final int stackedTabCount;
+
+ /**
+ * The space between tabs, which are part of a stack, in pixels.
+ */
+ private final int stackedTabSpacing;
+
+ /**
+ * The maximum camera distance, when tilting a tab, in pixels.
+ */
+ private final int maxCameraDistance;
+
+ /**
+ * The alpha of a tab, when it is swiped.
+ */
+ private final float swipedTabAlpha;
+
+ /**
+ * The scale of a tab, when it is swiped.
+ */
+ private final float swipedTabScale;
+
+ /**
+ * The duration of the animation, which is used to show the switcher.
+ */
+ private final long showSwitcherAnimationDuration;
+
+ /**
+ * The duration of the animation, which is used to hide the switcher.
+ */
+ private final long hideSwitcherAnimationDuration;
+
+ /**
+ * The duration of the animation, which is used to show or hide the toolbar.
+ */
+ private final long toolbarVisibilityAnimationDuration;
+
+ /**
+ * The delay of the animation, which is used to show or hide the toolbar.
+ */
+ private final long toolbarVisibilityAnimationDelay;
+
+ /**
+ * The duration of the animation, which is used to swipe tabs.
+ */
+ private final long swipeAnimationDuration;
+
+ /**
+ * The delay of the animation, which is used to remove all tabs.
+ */
+ private final long clearAnimationDelay;
+
+ /**
+ * The duration of the animation, which is used to relocate tabs.
+ */
+ private final long relocateAnimationDuration;
+
+ /**
+ * The delay of the animation, which is used to relocate tabs.
+ */
+ private final long relocateAnimationDelay;
+
+ /**
+ * The duration of the animation, which is used to revert overshoots.
+ */
+ private final long revertOvershootAnimationDuration;
+
+ /**
+ * The duration of a reveal animation.
+ */
+ private final long revealAnimationDuration;
+
+ /**
+ * The duration of a peek animation.
+ */
+ private final long peekAnimationDuration;
+
+ /**
+ * The maximum angle, tabs can be rotated by, when overshooting at the start, in degrees.
+ */
+ private final float maxStartOvershootAngle;
+
+ /**
+ * The maximum angle, tabs can be rotated by, when overshooting at the end, in degrees.
+ */
+ private final float maxEndOvershootAngle;
+
+ /**
+ * The drag handler, which is used by the layout.
+ */
+ private PhoneDragHandler dragHandler;
+
+ /**
+ * The view recycler, which allows to recycler the child views of tabs.
+ */
+ private ViewRecycler childViewRecycler;
+
+ /**
+ * The adapter, which allows to inflate the views, which are used to visualize tabs.
+ */
+ private PhoneRecyclerAdapter recyclerAdapter;
+
+ /**
+ * The view recycler, which allows to recycle the views, which are used to visualize tabs.
+ */
+ private AttachedViewRecycler viewRecycler;
+
+ /**
+ * The view group, which contains the tab switcher's tabs.
+ */
+ private ViewGroup tabContainer;
+
+ /**
+ * The toolbar, which is shown, when the tab switcher is shown.
+ */
+ private Toolbar toolbar;
+
+ /**
+ * The bottom margin of a view, which visualizes a tab.
+ */
+ private int tabViewBottomMargin;
+
+ /**
+ * The animation, which is used to show or hide the toolbar.
+ */
+ private ViewPropertyAnimator toolbarAnimation;
+
+ /**
+ * The index of the first visible tab.
+ */
+ private int firstVisibleIndex;
+
+ /**
+ * Adapts the log level.
+ */
+ private void adaptLogLevel() {
+ viewRecycler.setLogLevel(getModel().getLogLevel());
+ childViewRecycler.setLogLevel(getModel().getLogLevel());
+ }
+
+ /**
+ * Adapts the decorator.
+ */
+ private void adaptDecorator() {
+ childViewRecycler.setAdapter(getModel().getChildRecyclerAdapter());
+ recyclerAdapter.clearCachedPreviews();
+ }
+
+ /**
+ * Adapts the margin of the toolbar, which is shown, when the tab switcher is shown.
+ */
+ private void adaptToolbarMargin() {
+ FrameLayout.LayoutParams layoutParams =
+ (FrameLayout.LayoutParams) toolbar.getLayoutParams();
+ layoutParams.setMargins(getModel().getPaddingLeft(), getModel().getPaddingTop(),
+ getModel().getPaddingRight(), 0);
+ }
+
+ /**
+ * Calculates the positions of all tabs, when dragging towards the start.
+ *
+ * @param dragDistance
+ * The current drag distance in pixels as a {@link Float} value
+ */
+ private void calculatePositionsWhenDraggingToEnd(final float dragDistance) {
+ firstVisibleIndex = -1;
+ AbstractTabItemIterator iterator =
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler)
+ .start(Math.max(0, firstVisibleIndex)).create();
+ TabItem tabItem;
+ boolean abort = false;
+
+ while ((tabItem = iterator.next()) != null && !abort) {
+ if (getTabSwitcher().getCount() - tabItem.getIndex() > 1) {
+ abort = calculatePositionWhenDraggingToEnd(dragDistance, tabItem,
+ iterator.previous());
+
+ if (firstVisibleIndex == -1 && tabItem.getTag().getState() == State.FLOATING) {
+ firstVisibleIndex = tabItem.getIndex();
+ }
+ } else {
+ Pair pair =
+ clipTabPosition(getTabSwitcher().getCount(), tabItem.getIndex(),
+ tabItem.getTag().getPosition(), iterator.previous());
+ tabItem.getTag().setPosition(pair.first);
+ tabItem.getTag().setState(pair.second);
+ }
+
+ inflateOrRemoveView(tabItem);
+ }
+ }
+
+ /**
+ * Calculates the position of a specific tab, when dragging towards the end.
+ *
+ * @param dragDistance
+ * The current drag distance in pixels as a {@link Float} value
+ * @param tabItem
+ * The tab item, which corresponds to the tab, whose position should be calculated, as
+ * an instance of the class {@link TabItem}. The tab item may not be null
+ * @param predecessor
+ * The predecessor of the given tab item as an instance of the class {@link TabItem} or
+ * null, if the tab item does not have a predecessor
+ * @return True, if calculating the position of subsequent tabs can be omitted, false otherwise
+ */
+ private boolean calculatePositionWhenDraggingToEnd(final float dragDistance,
+ @NonNull final TabItem tabItem,
+ @Nullable final TabItem predecessor) {
+ if (predecessor == null || predecessor.getTag().getState() != State.FLOATING) {
+ if ((tabItem.getTag().getState() == State.STACKED_START_ATOP &&
+ tabItem.getIndex() == 0) || tabItem.getTag().getState() == State.FLOATING) {
+ float currentPosition = tabItem.getTag().getPosition();
+ float thresholdPosition = calculateEndPosition(tabItem.getIndex());
+ float newPosition = Math.min(currentPosition + dragDistance, thresholdPosition);
+ Pair pair =
+ clipTabPosition(getTabSwitcher().getCount(), tabItem.getIndex(),
+ newPosition, predecessor);
+ tabItem.getTag().setPosition(pair.first);
+ tabItem.getTag().setState(pair.second);
+ } else if (tabItem.getTag().getState() == State.STACKED_START_ATOP) {
+ return true;
+ }
+ } else {
+ float thresholdPosition = calculateEndPosition(tabItem.getIndex());
+ float newPosition =
+ Math.min(calculateNonLinearPosition(tabItem, predecessor), thresholdPosition);
+ Pair pair =
+ clipTabPosition(getTabSwitcher().getCount(), tabItem.getIndex(), newPosition,
+ predecessor);
+ tabItem.getTag().setPosition(pair.first);
+ tabItem.getTag().setState(pair.second);
+ }
+
+ return false;
+ }
+
+ /**
+ * Calculates the positions of all tabs, when dragging towards the end.
+ *
+ * @param dragDistance
+ * The current drag distance in pixels as a {@link Float} value
+ */
+ private void calculatePositionsWhenDraggingToStart(final float dragDistance) {
+ AbstractTabItemIterator iterator =
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler)
+ .start(Math.max(0, firstVisibleIndex)).create();
+ TabItem tabItem;
+ boolean abort = false;
+
+ while ((tabItem = iterator.next()) != null && !abort) {
+ if (getTabSwitcher().getCount() - tabItem.getIndex() > 1) {
+ abort = calculatePositionWhenDraggingToStart(dragDistance, tabItem,
+ iterator.previous());
+ } else {
+ Pair pair =
+ clipTabPosition(getTabSwitcher().getCount(), tabItem.getIndex(),
+ tabItem.getTag().getPosition(), iterator.previous());
+ tabItem.getTag().setPosition(pair.first);
+ tabItem.getTag().setState(pair.second);
+ }
+
+ inflateOrRemoveView(tabItem);
+ }
+
+ if (firstVisibleIndex > 0) {
+ int start = firstVisibleIndex - 1;
+ iterator = new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).reverse(true)
+ .start(start).create();
+
+ while ((tabItem = iterator.next()) != null) {
+ TabItem predecessor = iterator.previous();
+ float predecessorPosition = predecessor.getTag().getPosition();
+
+ if (tabItem.getIndex() < start) {
+ Pair pair =
+ clipTabPosition(getTabSwitcher().getCount(), predecessor.getIndex(),
+ predecessorPosition, tabItem);
+ predecessor.getTag().setPosition(pair.first);
+ predecessor.getTag().setState(pair.second);
+ inflateOrRemoveView(predecessor);
+
+ if (predecessor.getTag().getState() == State.FLOATING) {
+ firstVisibleIndex = predecessor.getIndex();
+ } else {
+ break;
+ }
+ }
+
+ float newPosition = predecessorPosition +
+ calculateMaxTabSpacing(getTabSwitcher().getCount(), predecessor);
+ tabItem.getTag().setPosition(newPosition);
+
+ if (!iterator.hasNext()) {
+ Pair pair =
+ clipTabPosition(getTabSwitcher().getCount(), tabItem.getIndex(),
+ newPosition, (TabItem) null);
+ tabItem.getTag().setPosition(pair.first);
+ tabItem.getTag().setState(pair.second);
+ inflateOrRemoveView(tabItem);
+
+ if (tabItem.getTag().getState() == State.FLOATING) {
+ firstVisibleIndex = tabItem.getIndex();
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Calculates the position of a specific tab, when dragging towards the start.
+ *
+ * @param dragDistance
+ * The current drag distance in pixels as a {@link Float} value
+ * @param tabItem
+ * The tab item, which corresponds to the tab, whose position should be calculated, as
+ * an instance of the class {@link TabItem}. The tab item may not be null
+ * @param predecessor
+ * The predecessor of the given tab item as an instance of the class {@link TabItem} or
+ * null, if the tab item does not have a predecessor
+ * @return True, if calculating the position of subsequent tabs can be omitted, false otherwise
+ */
+ private boolean calculatePositionWhenDraggingToStart(final float dragDistance,
+ @NonNull final TabItem tabItem,
+ @Nullable final TabItem predecessor) {
+ if (predecessor == null || predecessor.getTag().getState() != State.FLOATING ||
+ predecessor.getTag().getPosition() >
+ calculateAttachedPosition(getTabSwitcher().getCount())) {
+ if (tabItem.getTag().getState() == State.FLOATING) {
+ float currentPosition = tabItem.getTag().getPosition();
+ float newPosition = currentPosition + dragDistance;
+ Pair pair =
+ clipTabPosition(getTabSwitcher().getCount(), tabItem.getIndex(),
+ newPosition, predecessor);
+ tabItem.getTag().setPosition(pair.first);
+ tabItem.getTag().setState(pair.second);
+ } else if (tabItem.getTag().getState() == State.STACKED_START_ATOP) {
+ float currentPosition = tabItem.getTag().getPosition();
+ Pair pair =
+ clipTabPosition(getTabSwitcher().getCount(), tabItem.getIndex(),
+ currentPosition, predecessor);
+ tabItem.getTag().setPosition(pair.first);
+ tabItem.getTag().setState(pair.second);
+ return true;
+ } else if (tabItem.getTag().getState() == State.HIDDEN ||
+ tabItem.getTag().getState() == State.STACKED_START) {
+ return true;
+ }
+ } else {
+ float newPosition = calculateNonLinearPosition(tabItem, predecessor);
+ Pair pair =
+ clipTabPosition(getTabSwitcher().getCount(), tabItem.getIndex(), newPosition,
+ predecessor);
+ tabItem.getTag().setPosition(pair.first);
+ tabItem.getTag().setState(pair.second);
+ }
+
+ return false;
+ }
+
+ /**
+ * Calculates the non-linear position of a tab in relation to the position of its predecessor.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, whose non-linear position should be
+ * calculated, as an instance of the class {@link TabItem}. The tab item may not be
+ * null
+ * @param predecessor
+ * The predecessor as an instance of the class {@link TabItem}. The predecessor may not
+ * be null
+ * @return The position, which has been calculated, as a {@link Float} value
+ */
+ private float calculateNonLinearPosition(@NonNull final TabItem tabItem,
+ @NonNull final TabItem predecessor) {
+ float predecessorPosition = predecessor.getTag().getPosition();
+ float maxTabSpacing = calculateMaxTabSpacing(getTabSwitcher().getCount(), tabItem);
+ return calculateNonLinearPosition(predecessorPosition, maxTabSpacing);
+ }
+
+ /**
+ * Calculates the non-linear position of a tab in relation to the position of its predecessor.
+ *
+ * @param predecessorPosition
+ * The position of the predecessor in pixels as a {@link Float} value
+ * @param maxTabSpacing
+ * The maximum space between two neighboring tabs in pixels as a {@link Float} value
+ * @return The position, which has been calculated, as a {@link Float} value
+ */
+ private float calculateNonLinearPosition(final float predecessorPosition,
+ final float maxTabSpacing) {
+ float ratio = Math.min(1,
+ predecessorPosition / calculateAttachedPosition(getTabSwitcher().getCount()));
+ float minTabSpacing = calculateMinTabSpacing(getTabSwitcher().getCount());
+ return predecessorPosition - minTabSpacing - (ratio * (maxTabSpacing - minTabSpacing));
+ }
+
+ /**
+ * Calculates and returns the position of a specific tab, when located at the end.
+ *
+ * @param index
+ * The index of the tab, whose position should be calculated, as an {@link Integer}
+ * value
+ * @return The position, which has been calculated, as a {@link Float} value
+ */
+ private float calculateEndPosition(final int index) {
+ float defaultMaxTabSpacing = calculateMaxTabSpacing(getTabSwitcher().getCount(), null);
+ int selectedTabIndex = getTabSwitcher().getSelectedTabIndex();
+
+ if (selectedTabIndex > index) {
+ AbstractTabItemIterator iterator =
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create();
+ TabItem selectedTabItem = iterator.getItem(selectedTabIndex);
+ float selectedTabSpacing =
+ calculateMaxTabSpacing(getTabSwitcher().getCount(), selectedTabItem);
+ return (getTabSwitcher().getCount() - 2 - index) * defaultMaxTabSpacing +
+ selectedTabSpacing;
+ }
+
+ return (getTabSwitcher().getCount() - 1 - index) * defaultMaxTabSpacing;
+ }
+
+ /**
+ * Calculates and returns the position of a tab, when it is swiped.
+ *
+ * @return The position, which has been calculated, in pixels as an {@link Float} value
+ */
+ private float calculateSwipePosition() {
+ return getArithmetics().getTabContainerSize(Axis.ORTHOGONAL_AXIS, true);
+ }
+
+ /**
+ * Calculates and returns the maximum space between a specific tab and its predecessor. The
+ * maximum space is greater for the currently selected tab.
+ *
+ * @param count
+ * The total number of tabs, which are contained by the tabs switcher, as an {@link
+ * Integer} value
+ * @param tabItem
+ * The tab item, which corresponds to the tab, the maximum space should be returned for,
+ * as an instance of the class {@link TabItem} or null, if the default maximum space
+ * should be returned
+ * @return The maximum space between the given tab and its predecessor in pixels as a {@link
+ * Float} value
+ */
+ private float calculateMaxTabSpacing(final int count, @Nullable final TabItem tabItem) {
+ float totalSpace = getArithmetics().getTabContainerSize(Axis.DRAGGING_AXIS, false);
+ float maxTabSpacing;
+
+ if (count <= 2) {
+ maxTabSpacing = totalSpace * 0.66f;
+ } else if (count == 3) {
+ maxTabSpacing = totalSpace * 0.33f;
+ } else if (count == 4) {
+ maxTabSpacing = totalSpace * 0.3f;
+ } else {
+ maxTabSpacing = totalSpace * 0.25f;
+ }
+
+ return count > 4 && tabItem != null &&
+ tabItem.getTab() == getTabSwitcher().getSelectedTab() ?
+ maxTabSpacing * SELECTED_TAB_SPACING_RATIO : maxTabSpacing;
+ }
+
+ /**
+ * Calculates and returns the minimum space between two neighboring tabs.
+ *
+ * @param count
+ * The total number of tabs, which are contained by the tabs switcher, as an {@link
+ * Integer} value
+ * @return The minimum space between two neighboring tabs in pixels as a {@link Float} value
+ */
+ private float calculateMinTabSpacing(final int count) {
+ return calculateMaxTabSpacing(count, null) * MIN_TAB_SPACING_RATIO;
+ }
+
+ /**
+ * Calculates and returns the position on the dragging axis, where the distance between a tab
+ * and its predecessor should have reached the maximum.
+ *
+ * @param count
+ * The total number of tabs, which are contained by the tabs switcher, as an {@link
+ * Integer} value
+ * @return The position, which has been calculated, in pixels as an {@link Float} value
+ */
+ private float calculateAttachedPosition(final int count) {
+ float totalSpace = getArithmetics().getTabContainerSize(Axis.DRAGGING_AXIS, false);
+ float attachedPosition;
+
+ if (count == 3) {
+ attachedPosition = totalSpace * 0.66f;
+ } else if (count == 4) {
+ attachedPosition = totalSpace * 0.6f;
+ } else {
+ attachedPosition = totalSpace * 0.5f;
+ }
+
+ return attachedPosition;
+ }
+
+ /**
+ * Clips the position of a specific tab.
+ *
+ * @param count
+ * The total number of tabs, which are currently contained by the tab switcher, as an
+ * {@link Integer} value
+ * @param index
+ * The index of the tab, whose position should be clipped, as an {@link Integer} value
+ * @param position
+ * The position, which should be clipped, in pixels as a {@link Float} value
+ * @param predecessor
+ * The predecessor of the given tab item as an instance of the class {@link TabItem} or
+ * null, if the tab item does not have a predecessor
+ * @return A pair, which contains the position and state of the tab item, as an instance of the
+ * class {@link Pair}. The pair may not be null
+ */
+ @NonNull
+ private Pair clipTabPosition(final int count, final int index,
+ final float position,
+ @Nullable final TabItem predecessor) {
+ return clipTabPosition(count, index, position,
+ predecessor != null ? predecessor.getTag().getState() : null);
+ }
+
+ /**
+ * Clips the position of a specific tab.
+ *
+ * @param count
+ * The total number of tabs, which are currently contained by the tab switcher, as an
+ * {@link Integer} value
+ * @param index
+ * The index of the tab, whose position should be clipped, as an {@link Integer} value
+ * @param position
+ * The position, which should be clipped, in pixels as a {@link Float} value
+ * @param predecessorState
+ * The state of the predecessor of the given tab item as a value of the enum {@link
+ * State} or null, if the tab item does not have a predecessor
+ * @return A pair, which contains the position and state of the tab item, as an instance of the
+ * class {@link Pair}. The pair may not be null
+ */
+ private Pair clipTabPosition(final int count, final int index,
+ final float position,
+ @Nullable final State predecessorState) {
+ Pair startPair =
+ calculatePositionAndStateWhenStackedAtStart(count, index, predecessorState);
+ float startPosition = startPair.first;
+
+ if (position <= startPosition) {
+ State state = startPair.second;
+ return Pair.create(startPosition, state);
+ } else {
+ Pair endPair = calculatePositionAndStateWhenStackedAtEnd(index);
+ float endPosition = endPair.first;
+
+ if (position >= endPosition) {
+ State state = endPair.second;
+ return Pair.create(endPosition, state);
+ } else {
+ State state = State.FLOATING;
+ return Pair.create(position, state);
+ }
+ }
+ }
+
+ /**
+ * Calculates and returns the position and state of a specific tab, when stacked at the start.
+ *
+ * @param count
+ * The total number of tabs, which are currently contained by the tab switcher, as an
+ * {@link Integer} value
+ * @param index
+ * The index of the tab, whose position and state should be returned, as an {@link
+ * Integer} value
+ * @param predecessor
+ * The predecessor of the given tab item as an instance of the class {@link TabItem} or
+ * null, if the tab item does not have a predecessor
+ * @return A pair, which contains the position and state of the given tab item, when stacked at
+ * the start, as an instance of the class {@link Pair}. The pair may not be null
+ */
+ @NonNull
+ private Pair calculatePositionAndStateWhenStackedAtStart(final int count,
+ final int index,
+ @Nullable final TabItem predecessor) {
+ return calculatePositionAndStateWhenStackedAtStart(count, index,
+ predecessor != null ? predecessor.getTag().getState() : null);
+ }
+
+ /**
+ * Calculates and returns the position and state of a specific tab, when stacked at the start.
+ *
+ * @param count
+ * The total number of tabs, which are currently contained by the tab switcher, as an
+ * {@link Integer} value
+ * @param index
+ * The index of the tab, whose position and state should be returned, as an {@link
+ * Integer} value
+ * @param predecessorState
+ * The state of the predecessor of the given tab item as a value of the enum {@link
+ * State} or null, if the tab item does not have a predecessor
+ * @return A pair, which contains the position and state of the given tab item, when stacked at
+ * the start, as an instance of the class {@link Pair}. The pair may not be null
+ */
+ @NonNull
+ private Pair calculatePositionAndStateWhenStackedAtStart(final int count,
+ final int index,
+ @Nullable final State predecessorState) {
+ if ((count - index) <= stackedTabCount) {
+ float position = stackedTabSpacing * (count - (index + 1));
+ return Pair.create(position,
+ (predecessorState == null || predecessorState == State.FLOATING) ?
+ State.STACKED_START_ATOP : State.STACKED_START);
+ } else {
+ float position = stackedTabSpacing * stackedTabCount;
+ return Pair.create(position,
+ (predecessorState == null || predecessorState == State.FLOATING) ?
+ State.STACKED_START_ATOP : State.HIDDEN);
+ }
+ }
+
+ /**
+ * Calculates and returns the position and state of a specific tab, when stacked at the end.
+ *
+ * @param index
+ * The index of the tab, whose position and state should be returned, as an {@link
+ * Integer} value
+ * @return A pair, which contains the position and state of the given tab item, when stacked at
+ * the end, as an instance of the class {@link Pair}. The pair may not be null
+ */
+ @NonNull
+ private Pair calculatePositionAndStateWhenStackedAtEnd(final int index) {
+ float size = getArithmetics().getTabContainerSize(Axis.DRAGGING_AXIS, false);
+
+ if (index < stackedTabCount) {
+ float position = size - tabInset - (stackedTabSpacing * (index + 1));
+ return Pair.create(position, State.STACKED_END);
+ } else {
+ float position = size - tabInset - (stackedTabSpacing * stackedTabCount);
+ return Pair.create(position, State.HIDDEN);
+ }
+ }
+
+ /**
+ * The method, which is invoked on implementing subclasses in order to retrieve, whether the
+ * tabs are overshooting at the start.
+ *
+ * @return True, if the tabs are overshooting at the start, false otherwise
+ */
+ private boolean isOvershootingAtStart() {
+ if (getTabSwitcher().getCount() <= 1) {
+ return true;
+ } else {
+ AbstractTabItemIterator iterator =
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create();
+ TabItem tabItem = iterator.getItem(0);
+ return tabItem.getTag().getState() == State.STACKED_START_ATOP;
+ }
+ }
+
+ /**
+ * The method, which is invoked on implementing subclasses in order to retrieve, whether the
+ * tabs are overshooting at the end.
+ *
+ * @param iterator
+ * An iterator, which allows to iterate the tabs, which are contained by the tab
+ * switcher, as an instance of the class {@link AbstractTabItemIterator}. The iterator
+ * may not be null
+ * @return True, if the tabs are overshooting at the end, false otherwise
+ */
+ private boolean isOvershootingAtEnd(@NonNull final AbstractTabItemIterator iterator) {
+ if (getTabSwitcher().getCount() <= 1) {
+ return true;
+ } else {
+ TabItem lastTabItem = iterator.getItem(getTabSwitcher().getCount() - 1);
+ TabItem predecessor = iterator.getItem(getTabSwitcher().getCount() - 2);
+ return Math.round(predecessor.getTag().getPosition()) >=
+ Math.round(calculateMaxTabSpacing(getTabSwitcher().getCount(), lastTabItem));
+ }
+ }
+
+ /**
+ * Calculates and returns the bottom margin of a view, which visualizes a tab.
+ *
+ * @param view
+ * The view, whose bottom margin should be calculated, as an instance of the class
+ * {@link View}. The view may not be null
+ * @return The bottom margin, which has been calculated, in pixels as an {@link Integer} value
+ */
+ private int calculateBottomMargin(@NonNull final View view) {
+ float tabHeight = (view.getHeight() - 2 * tabInset) * getArithmetics().getScale(view, true);
+ float containerHeight = getArithmetics().getTabContainerSize(Axis.Y_AXIS, false);
+ int stackHeight = getTabSwitcher().getLayout() == Layout.PHONE_LANDSCAPE ? 0 :
+ stackedTabCount * stackedTabSpacing;
+ return Math.round(tabHeight + tabInset + stackHeight - containerHeight);
+ }
+
+ /**
+ * Animates the bottom margin of a specific view.
+ *
+ * @param view
+ * The view, whose bottom margin should be animated, as an instance of the class {@link
+ * View}. The view may not be null
+ * @param margin
+ * The bottom margin, which should be set by the animation, as an {@link Integer} value
+ * @param duration
+ * The duration of the animation in milliseconds as a {@link Long} value
+ * @param delay
+ * The delay of the animation in milliseconds as a {@link Long} value
+ */
+ private void animateBottomMargin(@NonNull final View view, final int margin,
+ final long duration, final long delay) {
+ FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams();
+ final int initialMargin = layoutParams.bottomMargin;
+ ValueAnimator animation = ValueAnimator.ofInt(margin - initialMargin);
+ animation.setDuration(duration);
+ animation.addListener(new AnimationListenerWrapper(null));
+ animation.setInterpolator(new AccelerateDecelerateInterpolator());
+ animation.setStartDelay(delay);
+ animation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ FrameLayout.LayoutParams layoutParams =
+ (FrameLayout.LayoutParams) view.getLayoutParams();
+ layoutParams.bottomMargin = initialMargin + (int) animation.getAnimatedValue();
+ view.setLayoutParams(layoutParams);
+ }
+
+ });
+
+ animation.start();
+ }
+
+ /**
+ * Animates the visibility of the toolbar, which is shown, when the tab switcher is shown.
+ *
+ * @param visible
+ * True, if the toolbar should become visible, false otherwise
+ * @param delay
+ * The delay of the animation in milliseconds as a {@link Long} value
+ */
+ private void animateToolbarVisibility(final boolean visible, final long delay) {
+ if (toolbarAnimation != null) {
+ toolbarAnimation.cancel();
+ }
+
+ float targetAlpha = visible ? 1 : 0;
+
+ if (toolbar.getAlpha() != targetAlpha) {
+ toolbarAnimation = toolbar.animate();
+ toolbarAnimation.setInterpolator(new AccelerateDecelerateInterpolator());
+ toolbarAnimation.setDuration(toolbarVisibilityAnimationDuration);
+ toolbarAnimation.setStartDelay(delay);
+ toolbarAnimation.alpha(targetAlpha);
+ toolbarAnimation.start();
+ }
+ }
+
+ /**
+ * Shows the tab switcher in an animated manner.
+ */
+ private void animateShowSwitcher() {
+ TabItem[] tabItems = calculateInitialTabItems(-1, -1);
+ AbstractTabItemIterator iterator = new InitialTabItemIterator(tabItems, false, 0);
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ if (tabItem.getTab() == getModel().getSelectedTab() || tabItem.isVisible()) {
+ viewRecycler.inflate(tabItem);
+ View view = tabItem.getView();
+
+ if (!ViewCompat.isLaidOut(view)) {
+ view.getViewTreeObserver().addOnGlobalLayoutListener(
+ new LayoutListenerWrapper(view,
+ createShowSwitcherLayoutListener(tabItem)));
+ } else {
+ animateShowSwitcher(tabItem, createUpdateViewAnimationListener(tabItem));
+ }
+ }
+ }
+
+ animateToolbarVisibility(getModel().areToolbarsShown(), toolbarVisibilityAnimationDelay);
+ }
+
+ /**
+ * Calculates and returns the tab items, which correspond to the tabs, when the tab switcher is
+ * shown initially.
+ *
+ * @param firstVisibleTabIndex
+ * The index of the first visible tab as an {@link Integer} value or -1, if the index is
+ * unknown
+ * @param firstVisibleTabPosition
+ * The position of the first visible tab in pixels as a {@link Float} value or -1, if
+ * the position is unknown
+ * @return An array, which contains the tab items, as an array of the type {@link TabItem}. The
+ * array may not be null
+ */
+ @NonNull
+ private TabItem[] calculateInitialTabItems(final int firstVisibleTabIndex,
+ final float firstVisibleTabPosition) {
+ dragHandler.reset(getDragThreshold());
+ firstVisibleIndex = -1;
+ TabItem[] tabItems = new TabItem[getModel().getCount()];
+
+ if (!getModel().isEmpty()) {
+ int selectedTabIndex = getModel().getSelectedTabIndex();
+ float attachedPosition = calculateAttachedPosition(getModel().getCount());
+ int referenceIndex = firstVisibleTabIndex != -1 && firstVisibleTabPosition != -1 ?
+ firstVisibleTabIndex : selectedTabIndex;
+ float referencePosition = firstVisibleTabIndex != -1 && firstVisibleTabPosition != -1 ?
+ firstVisibleTabPosition : attachedPosition;
+ referencePosition = Math.min(calculateEndPosition(referenceIndex), referencePosition);
+ AbstractTabItemIterator iterator =
+ new InitialTabItemIterator(tabItems, false, referenceIndex);
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ TabItem predecessor = iterator.previous();
+ float position;
+
+ if (tabItem.getIndex() == getModel().getCount() - 1) {
+ position = 0;
+ } else if (tabItem.getIndex() == referenceIndex) {
+ position = referencePosition;
+ } else {
+ position = calculateNonLinearPosition(tabItem, predecessor);
+ }
+
+ Pair pair =
+ clipTabPosition(getModel().getCount(), tabItem.getIndex(), position,
+ predecessor);
+ tabItem.getTag().setPosition(pair.first);
+ tabItem.getTag().setState(pair.second);
+
+ if (firstVisibleIndex == -1 && pair.second != State.STACKED_END &&
+ pair.second != State.HIDDEN) {
+ firstVisibleIndex = tabItem.getIndex();
+ }
+
+ if (pair.second == State.STACKED_START || pair.second == State.STACKED_START_ATOP) {
+ break;
+ }
+ }
+
+ boolean overshooting =
+ referenceIndex == getModel().getCount() - 1 || isOvershootingAtEnd(iterator);
+ iterator = new InitialTabItemIterator(tabItems, true, referenceIndex - 1);
+ float minTabSpacing = calculateMinTabSpacing(getModel().getCount());
+ float defaultTabSpacing = calculateMaxTabSpacing(getModel().getCount(), null);
+ TabItem selectedTabItem =
+ TabItem.create(getTabSwitcher(), viewRecycler, selectedTabIndex);
+ float maxTabSpacing = calculateMaxTabSpacing(getModel().getCount(), selectedTabItem);
+ TabItem currentReferenceTabItem = iterator.getItem(referenceIndex);
+
+ while ((tabItem = iterator.next()) != null &&
+ (overshooting || tabItem.getIndex() < referenceIndex)) {
+ float currentTabSpacing =
+ calculateMaxTabSpacing(getModel().getCount(), currentReferenceTabItem);
+ TabItem predecessor = iterator.peek();
+ Pair pair;
+
+ if (overshooting) {
+ float position;
+
+ if (referenceIndex > tabItem.getIndex()) {
+ position = maxTabSpacing +
+ ((getModel().getCount() - 1 - tabItem.getIndex() - 1) *
+ defaultTabSpacing);
+ } else {
+ position = (getModel().getCount() - 1 - tabItem.getIndex()) *
+ defaultTabSpacing;
+ }
+
+ pair = clipTabPosition(getModel().getCount(), tabItem.getIndex(), position,
+ predecessor);
+ } else if (referencePosition >= attachedPosition - currentTabSpacing) {
+ float position;
+
+ if (selectedTabIndex > tabItem.getIndex() &&
+ selectedTabIndex <= referenceIndex) {
+ position = referencePosition + maxTabSpacing +
+ ((referenceIndex - tabItem.getIndex() - 1) * defaultTabSpacing);
+ } else {
+ position = referencePosition +
+ ((referenceIndex - tabItem.getIndex()) * defaultTabSpacing);
+ }
+
+ pair = clipTabPosition(getModel().getCount(), tabItem.getIndex(), position,
+ predecessor);
+ } else {
+ TabItem successor = iterator.previous();
+ float successorPosition = successor.getTag().getPosition();
+ float position = (attachedPosition * (successorPosition + minTabSpacing)) /
+ (minTabSpacing + attachedPosition - currentTabSpacing);
+ pair = clipTabPosition(getModel().getCount(), tabItem.getIndex(), position,
+ predecessor);
+
+ if (pair.first >= attachedPosition - currentTabSpacing) {
+ currentReferenceTabItem = tabItem;
+ referencePosition = pair.first;
+ referenceIndex = tabItem.getIndex();
+ }
+ }
+
+ tabItem.getTag().setPosition(pair.first);
+ tabItem.getTag().setState(pair.second);
+
+ if ((firstVisibleIndex == -1 || firstVisibleIndex > tabItem.getIndex()) &&
+ pair.second == State.FLOATING) {
+ firstVisibleIndex = tabItem.getIndex();
+ }
+ }
+ }
+
+ dragHandler.setCallback(this);
+ return tabItems;
+ }
+
+ /**
+ * Adds all tabs, which are contained by an array, to the tab switcher.
+ *
+ * @param index
+ * The index, the first tab should be added at, as an {@link Integer} value
+ * @param tabs
+ * The array, which contains the tabs, which should be added, as an array of the type
+ * {@link Tab}. The array may not be null
+ * @param animation
+ * The animation, which should be used to add the tabs, as an instance of the class
+ * {@link Animation}. The animation may not be null
+ */
+ private void addAllTabs(final int index, @NonNull final Tab[] tabs,
+ @NonNull final Animation animation) {
+ if (tabs.length > 0) {
+ if (getModel().isSwitcherShown()) {
+ SwipeAnimation swipeAnimation =
+ animation instanceof SwipeAnimation ? (SwipeAnimation) animation :
+ new SwipeAnimation.Builder().create();
+ TabItem[] tabItems = new TabItem[tabs.length];
+ OnGlobalLayoutListener compoundListener = new CompoundLayoutListener(tabs.length,
+ createSwipeLayoutListener(tabItems, swipeAnimation));
+
+ for (int i = 0; i < tabs.length; i++) {
+ Tab tab = tabs[i];
+ TabItem tabItem = new TabItem(index + i, tab);
+ tabItems[i] = tabItem;
+ inflateView(tabItem, compoundListener);
+ }
+ } else if (!getModel().isSwitcherShown()) {
+ toolbar.setAlpha(0);
+
+ if (getModel().getSelectedTab() == tabs[0]) {
+ TabItem tabItem = TabItem.create(getTabSwitcher(), viewRecycler, index);
+ inflateView(tabItem, createAddSelectedTabLayoutListener(tabItem));
+ }
+ }
+ }
+ }
+
+ /**
+ * Animates the position and size of a specific tab item in order to show the tab switcher.
+ *
+ * @param tabItem
+ * The tab item, which should be animated, as an instance of the class {@link TabItem}.
+ * The tab item may not be null
+ * @param listener
+ * The listener, which should be notified about the animation's progress, as an instance
+ * of the type {@link AnimatorListener} or null, if no listener should be notified
+ */
+ private void animateShowSwitcher(@NonNull final TabItem tabItem,
+ @Nullable final AnimatorListener listener) {
+ animateShowSwitcher(tabItem, showSwitcherAnimationDuration,
+ new AccelerateDecelerateInterpolator(), listener);
+ }
+
+ /**
+ * Animates the position and size of a specific tab in order to show the tab switcher.
+ *
+ * @param tabItem
+ * The tab item, which should be animated, as an instance of the class {@link TabItem}.
+ * The tab item may not be null
+ * @param duration
+ * The duration of the animation in milliseconds as a {@link Long} value
+ * @param interpolator
+ * The interpolator, which should be used by the animation, as an instance of the type
+ * {@link Interpolator}. The interpolator may not be null
+ * @param listener
+ * The listener, which should be notified about the animation's progress, as an instance
+ * of the type {@link AnimatorListener} or null, if no listener should be notified
+ */
+ private void animateShowSwitcher(@NonNull final TabItem tabItem, final long duration,
+ @NonNull final Interpolator interpolator,
+ @Nullable final AnimatorListener listener) {
+ View view = tabItem.getView();
+ FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams();
+ view.setX(layoutParams.leftMargin);
+ view.setY(layoutParams.topMargin);
+ getArithmetics().setScale(Axis.DRAGGING_AXIS, view, 1);
+ getArithmetics().setScale(Axis.ORTHOGONAL_AXIS, view, 1);
+ getArithmetics().setPivot(Axis.DRAGGING_AXIS, view,
+ getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE));
+ getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view,
+ getArithmetics().getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.NONE));
+ float scale = getArithmetics().getScale(view, true);
+ int selectedTabIndex = getModel().getSelectedTabIndex();
+
+ if (tabItem.getIndex() < selectedTabIndex) {
+ getArithmetics().setPosition(Axis.DRAGGING_AXIS, view,
+ getArithmetics().getTabContainerSize(Axis.DRAGGING_AXIS));
+ } else if (tabItem.getIndex() > selectedTabIndex) {
+ getArithmetics().setPosition(Axis.DRAGGING_AXIS, view,
+ getTabSwitcher().getLayout() == Layout.PHONE_LANDSCAPE ? 0 :
+ layoutParams.topMargin);
+ }
+
+ if (tabViewBottomMargin == -1) {
+ tabViewBottomMargin = calculateBottomMargin(view);
+ }
+
+ animateBottomMargin(view, tabViewBottomMargin, duration, 0);
+ ViewPropertyAnimator animation = view.animate();
+ animation.setDuration(duration);
+ animation.setInterpolator(interpolator);
+ animation.setListener(new AnimationListenerWrapper(listener));
+ getArithmetics().animateScale(Axis.DRAGGING_AXIS, animation, scale);
+ getArithmetics().animateScale(Axis.ORTHOGONAL_AXIS, animation, scale);
+ getArithmetics().animatePosition(Axis.DRAGGING_AXIS, animation, view,
+ tabItem.getTag().getPosition(), true);
+ getArithmetics().animatePosition(Axis.ORTHOGONAL_AXIS, animation, view, 0, true);
+ animation.setStartDelay(0);
+ animation.start();
+ }
+
+ /**
+ * Hides the tab switcher in an animated manner.
+ */
+ private void animateHideSwitcher() {
+ dragHandler.setCallback(null);
+ TabItemIterator iterator =
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create();
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ if (tabItem.isInflated()) {
+ animateHideSwitcher(tabItem,
+ tabItem.getIndex() == getModel().getSelectedTabIndex() ?
+ createHideSwitcherAnimationListener() : null);
+ } else if (tabItem.getTab() == getModel().getSelectedTab()) {
+ inflateAndUpdateView(tabItem, createHideSwitcherLayoutListener(tabItem));
+ }
+ }
+
+ animateToolbarVisibility(getModel().areToolbarsShown() && getModel().isEmpty(), 0);
+ }
+
+ /**
+ * Animates the position and size of a specific tab item in order to hide the tab switcher.
+ *
+ * @param tabItem
+ * The tab item, which should be animated, as an instance of the class {@link TabItem}.
+ * The tab item may not be null
+ * @param listener
+ * The listener, which should be notified about the animation's progress, as an instance
+ * of the type {@link AnimatorListener} or null, if no listener should be notified
+ */
+ private void animateHideSwitcher(@NonNull final TabItem tabItem,
+ @Nullable final AnimatorListener listener) {
+ animateHideSwitcher(tabItem, hideSwitcherAnimationDuration,
+ new AccelerateDecelerateInterpolator(), 0, listener);
+ }
+
+ /**
+ * Animates the position and size of a specific tab item in order to hide the tab switcher.
+ *
+ * @param tabItem
+ * The tab item, which should be animated, as an instance of the class {@link TabItem}.
+ * The tab item may not be null
+ * @param duration
+ * The duration of the animation in milliseconds as a {@link Long} value
+ * @param interpolator
+ * The interpolator, which should be used by the animation, as an instance of the class
+ * {@link Interpolator}. The interpolator may not be null
+ * @param delay
+ * The delay of the animation in milliseconds as a {@link Long} value
+ * @param listener
+ * The listener, which should be notified about the animation's progress, as an instance
+ * of the type {@link AnimatorListener} or null, if no listener should be notified
+ */
+ private void animateHideSwitcher(@NonNull final TabItem tabItem, final long duration,
+ @NonNull final Interpolator interpolator, final long delay,
+ @Nullable final AnimatorListener listener) {
+ View view = tabItem.getView();
+ animateBottomMargin(view, -(tabInset + tabBorderWidth), duration, delay);
+ ViewPropertyAnimator animation = view.animate();
+ animation.setDuration(duration);
+ animation.setInterpolator(interpolator);
+ animation.setListener(new AnimationListenerWrapper(listener));
+ getArithmetics().animateScale(Axis.DRAGGING_AXIS, animation, 1);
+ getArithmetics().animateScale(Axis.ORTHOGONAL_AXIS, animation, 1);
+ FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams();
+ getArithmetics().animatePosition(Axis.ORTHOGONAL_AXIS, animation, view,
+ getTabSwitcher().getLayout() == Layout.PHONE_LANDSCAPE ? layoutParams.topMargin : 0,
+ false);
+ int selectedTabIndex = getModel().getSelectedTabIndex();
+
+ if (tabItem.getIndex() < selectedTabIndex) {
+ getArithmetics().animatePosition(Axis.DRAGGING_AXIS, animation, view,
+ getArithmetics().getTabContainerSize(Axis.DRAGGING_AXIS), false);
+ } else if (tabItem.getIndex() > selectedTabIndex) {
+ getArithmetics().animatePosition(Axis.DRAGGING_AXIS, animation, view,
+ getTabSwitcher().getLayout() == Layout.PHONE_LANDSCAPE ? 0 :
+ layoutParams.topMargin, false);
+ } else {
+ getArithmetics().animatePosition(Axis.DRAGGING_AXIS, animation, view,
+ getTabSwitcher().getLayout() == Layout.PHONE_LANDSCAPE ? 0 :
+ layoutParams.topMargin, false);
+ }
+
+ animation.setStartDelay(delay);
+ animation.start();
+ }
+
+ /**
+ * Animates the position, size and alpha of a specific tab item in order to swipe it
+ * orthogonally.
+ *
+ * @param tabItem
+ * The tab item, which should be animated, as an instance of the class {@link TabItem}.
+ * The tab item may not be null
+ * @param remove
+ * True, if the tab should be removed after the animation has finished, false otherwise
+ * @param delay
+ * The delay after which the animation should be started in milliseconds as a {@link
+ * Long} value
+ * @param swipeAnimation
+ * The animation, which should be used, as an instance of the class {@link
+ * SwipeAnimation}. The animation may not be null
+ * @param listener
+ * The listener, which should be notified about the progress of the animation, as an
+ * instance of the type {@link AnimatorListener} or null, if no listener should be
+ * notified
+ */
+ private void animateSwipe(@NonNull final TabItem tabItem, final boolean remove,
+ final long delay, @NonNull final SwipeAnimation swipeAnimation,
+ @Nullable final AnimatorListener listener) {
+ View view = tabItem.getView();
+ float currentScale = getArithmetics().getScale(view, true);
+ float swipePosition = calculateSwipePosition();
+ float targetPosition = remove ?
+ (swipeAnimation.getDirection() == SwipeDirection.LEFT ? -1 * swipePosition :
+ swipePosition) : 0;
+ float currentPosition = getArithmetics().getPosition(Axis.ORTHOGONAL_AXIS, view);
+ float distance = Math.abs(targetPosition - currentPosition);
+ long animationDuration = swipeAnimation.getDuration() != -1 ? swipeAnimation.getDuration() :
+ Math.round(swipeAnimationDuration * (distance / swipePosition));
+ ViewPropertyAnimator animation = view.animate();
+ animation.setInterpolator(
+ swipeAnimation.getInterpolator() != null ? swipeAnimation.getInterpolator() :
+ new AccelerateDecelerateInterpolator());
+ animation.setListener(new AnimationListenerWrapper(listener));
+ animation.setDuration(animationDuration);
+ getArithmetics()
+ .animatePosition(Axis.ORTHOGONAL_AXIS, animation, view, targetPosition, true);
+ getArithmetics().animateScale(Axis.ORTHOGONAL_AXIS, animation,
+ remove ? swipedTabScale * currentScale : currentScale);
+ getArithmetics().animateScale(Axis.DRAGGING_AXIS, animation,
+ remove ? swipedTabScale * currentScale : currentScale);
+ animation.alpha(remove ? swipedTabAlpha : 1);
+ animation.setStartDelay(delay);
+ animation.start();
+ }
+
+ /**
+ * Animates the removal of a specific tab item.
+ *
+ * @param removedTabItem
+ * The tab item, which should be animated, as an instance of the class {@link TabItem}.
+ * The tab item may not be null
+ * @param swipeAnimation
+ * The animation, which should be used, as an instance of the class {@link
+ * SwipeAnimation}. The animation may not be null
+ */
+ private void animateRemove(@NonNull final TabItem removedTabItem,
+ @NonNull final SwipeAnimation swipeAnimation) {
+ View view = removedTabItem.getView();
+ getArithmetics().setPivot(Axis.DRAGGING_AXIS, view,
+ getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.SWIPE));
+ getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view,
+ getArithmetics().getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.SWIPE));
+ animateSwipe(removedTabItem, true, 0, swipeAnimation,
+ createRemoveAnimationListener(removedTabItem));
+ }
+
+ /**
+ * Animates the position of a specific tab item in order to relocate it.
+ *
+ * @param tabItem
+ * The tab item, which should be animated, as an instance of the class {@link TabItem}.
+ * The tab item may not be null
+ * @param position
+ * The position, the tab should be relocated to, in pixels as a {@link Float} value
+ * @param tag
+ * The tag, which should be applied to the given tab item, as an instance of the class
+ * {@link Tag} or null, if no tag should be applied
+ * @param delay
+ * The delay of the relocate animation in milliseconds as a {@link Long} value
+ * @param listener
+ * The listener, which should be notified about the progress of the relocate animation,
+ * as an instance of the type {@link AnimatorListener} or null, if no listener should be
+ * notified
+ */
+ private void animateRelocate(@NonNull final TabItem tabItem, final float position,
+ @Nullable final Tag tag, final long delay,
+ @Nullable final AnimatorListener listener) {
+ if (tag != null) {
+ tabItem.getView().setTag(R.id.tag_properties, tag);
+ tabItem.setTag(tag);
+ }
+
+ View view = tabItem.getView();
+ ViewPropertyAnimator animation = view.animate();
+ animation.setListener(new AnimationListenerWrapper(listener));
+ animation.setInterpolator(new AccelerateDecelerateInterpolator());
+ animation.setDuration(relocateAnimationDuration);
+ getArithmetics().animatePosition(Axis.DRAGGING_AXIS, animation, view, position, true);
+ animation.setStartDelay(delay);
+ animation.start();
+ }
+
+ /**
+ * Animates reverting an overshoot at the start.
+ */
+ private void animateRevertStartOvershoot() {
+ boolean tilted = animateTilt(new AccelerateInterpolator(), maxStartOvershootAngle,
+ createRevertStartOvershootAnimationListener());
+
+ if (!tilted) {
+ animateRevertStartOvershoot(new AccelerateDecelerateInterpolator());
+ }
+ }
+
+ /**
+ * Animates reverting an overshoot at the start using a specific interpolator.
+ *
+ * @param interpolator
+ * The interpolator, which should be used by the animation, as an instance of the type
+ * {@link Interpolator}. The interpolator may not be null
+ */
+ private void animateRevertStartOvershoot(@NonNull final Interpolator interpolator) {
+ TabItem tabItem = TabItem.create(getTabSwitcher(), viewRecycler, 0);
+ View view = tabItem.getView();
+ getArithmetics().setPivot(Axis.DRAGGING_AXIS, view,
+ getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE));
+ getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view,
+ getArithmetics().getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.NONE));
+ float position = getArithmetics().getPosition(Axis.DRAGGING_AXIS, view);
+ float targetPosition = tabItem.getTag().getPosition();
+ final float startPosition = getArithmetics().getPosition(Axis.DRAGGING_AXIS, view);
+ ValueAnimator animation = ValueAnimator.ofFloat(targetPosition - position);
+ animation.setDuration(Math.round(revertOvershootAnimationDuration * Math.abs(
+ (targetPosition - position) / (float) (stackedTabCount * stackedTabSpacing))));
+ animation.addListener(new AnimationListenerWrapper(null));
+ animation.setInterpolator(interpolator);
+ animation.setStartDelay(0);
+ animation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+
+ @Override
+ public void onAnimationUpdate(final ValueAnimator animation) {
+ TabItemIterator iterator =
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create();
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ if (tabItem.getIndex() == 0) {
+ View view = tabItem.getView();
+ getArithmetics().setPosition(Axis.DRAGGING_AXIS, view,
+ startPosition + (float) animation.getAnimatedValue());
+ } else if (tabItem.isInflated()) {
+ View firstView = iterator.first().getView();
+ View view = tabItem.getView();
+ view.setVisibility(
+ getArithmetics().getPosition(Axis.DRAGGING_AXIS, firstView) <=
+ getArithmetics().getPosition(Axis.DRAGGING_AXIS, view) ?
+ View.INVISIBLE : View.VISIBLE);
+ }
+ }
+ }
+
+ });
+
+ animation.start();
+ }
+
+ /**
+ * Animates reverting an overshoot at the end.
+ */
+ private void animateRevertEndOvershoot() {
+ animateTilt(new AccelerateDecelerateInterpolator(), maxEndOvershootAngle, null);
+ }
+
+ /**
+ * Animates to rotation of all tabs to be reset to normal.
+ *
+ * @param interpolator
+ * The interpolator, which should be used by the animation, as an instance of the type
+ * {@link Interpolator}. The interpolator may not be null
+ * @param maxAngle
+ * The angle, the tabs may be rotated by at maximum, in degrees as a {@link Float}
+ * value
+ * @param listener
+ * The listener, which should be notified about the animation's progress, as an instance
+ * of the type {@link AnimatorListener} or null, if no listener should be notified
+ * @return True, if at least one tab was animated, false otherwise
+ */
+ private boolean animateTilt(@NonNull final Interpolator interpolator, final float maxAngle,
+ @Nullable final AnimatorListener listener) {
+ TabItemIterator iterator =
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).reverse(true).create();
+ TabItem tabItem;
+ boolean result = false;
+
+ while ((tabItem = iterator.next()) != null) {
+ if (tabItem.isInflated()) {
+ View view = tabItem.getView();
+
+ if (getArithmetics().getRotation(Axis.ORTHOGONAL_AXIS, view) != 0) {
+ ViewPropertyAnimator animation = view.animate();
+ animation.setListener(new AnimationListenerWrapper(
+ createRevertOvershootAnimationListener(view,
+ !result ? listener : null)));
+ animation.setDuration(Math.round(revertOvershootAnimationDuration *
+ (Math.abs(getArithmetics().getRotation(Axis.ORTHOGONAL_AXIS, view)) /
+ maxAngle)));
+ animation.setInterpolator(interpolator);
+ getArithmetics().animateRotation(Axis.ORTHOGONAL_AXIS, animation, 0);
+ animation.setStartDelay(0);
+ animation.start();
+ result = true;
+ }
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Starts a reveal animation to add a specific tab.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, which should be added, as an instance of
+ * the class {@link TabItem}. The tab item may not be null
+ * @param revealAnimation
+ * The reveal animation, which should be started, as an instance of the class {@link
+ * RevealAnimation}. The reveal animation may not be null
+ */
+ private void animateReveal(@NonNull final TabItem tabItem,
+ @NonNull final RevealAnimation revealAnimation) {
+ tabViewBottomMargin = -1;
+ recyclerAdapter.clearCachedPreviews();
+ dragHandler.setCallback(null);
+ View view = tabItem.getView();
+ ViewPropertyAnimator animation = view.animate();
+ animation.setInterpolator(
+ revealAnimation.getInterpolator() != null ? revealAnimation.getInterpolator() :
+ new AccelerateDecelerateInterpolator());
+ animation.setListener(new AnimationListenerWrapper(createHideSwitcherAnimationListener()));
+ animation.setStartDelay(0);
+ animation.setDuration(revealAnimation.getDuration() != -1 ? revealAnimation.getDuration() :
+ revealAnimationDuration);
+ getArithmetics().animateScale(Axis.DRAGGING_AXIS, animation, 1);
+ getArithmetics().animateScale(Axis.ORTHOGONAL_AXIS, animation, 1);
+ animation.start();
+ animateToolbarVisibility(getModel().areToolbarsShown() && getModel().isEmpty(), 0);
+ }
+
+ /**
+ * Starts a peek animation to add a specific tab.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, which should be added, as an instance of
+ * the class {@link TabItem}. The tab item may not be null
+ * @param duration
+ * The duration of the animation in milliseconds as a {@link Long} value
+ * @param interpolator
+ * The interpolator, which should be used by the animation, as an instance of the type
+ * {@link Interpolator}. The interpolator may not be null
+ * @param peekPosition
+ * The position on the dragging axis, the tab should be moved to, in pixels as a {@link
+ * Float} value
+ * @param peekAnimation
+ * The peek animation, which has been used to add the tab, as an instance of the class
+ * {@link PeekAnimation}. The peek animation may not be null
+ */
+ private void animatePeek(@NonNull final TabItem tabItem, final long duration,
+ @NonNull final Interpolator interpolator, final float peekPosition,
+ @NonNull final PeekAnimation peekAnimation) {
+ PhoneTabViewHolder viewHolder = tabItem.getViewHolder();
+ viewHolder.closeButton.setVisibility(View.GONE);
+ View view = tabItem.getView();
+ float x = peekAnimation.getX();
+ float y = peekAnimation.getY() + tabTitleContainerHeight;
+ FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) view.getLayoutParams();
+ view.setAlpha(1f);
+ getArithmetics().setPivot(Axis.X_AXIS, view, x);
+ getArithmetics().setPivot(Axis.Y_AXIS, view, y);
+ view.setX(layoutParams.leftMargin);
+ view.setY(layoutParams.topMargin);
+ getArithmetics().setScale(Axis.DRAGGING_AXIS, view, 0);
+ getArithmetics().setScale(Axis.ORTHOGONAL_AXIS, view, 0);
+ ViewPropertyAnimator animation = view.animate();
+ animation.setInterpolator(interpolator);
+ animation.setListener(
+ new AnimationListenerWrapper(createPeekAnimationListener(tabItem, peekAnimation)));
+ animation.setStartDelay(0);
+ animation.setDuration(duration);
+ getArithmetics().animateScale(Axis.DRAGGING_AXIS, animation, 1);
+ getArithmetics().animateScale(Axis.ORTHOGONAL_AXIS, animation, 1);
+ getArithmetics().animatePosition(Axis.DRAGGING_AXIS, animation, view, peekPosition, true);
+ animation.start();
+ int selectedTabIndex = getModel().getSelectedTabIndex();
+ TabItem selectedTabItem = TabItem.create(getModel(), viewRecycler, selectedTabIndex);
+ viewRecycler.inflate(selectedTabItem);
+ selectedTabItem.getTag().setPosition(0);
+ PhoneTabViewHolder selectedTabViewHolder = selectedTabItem.getViewHolder();
+ selectedTabViewHolder.closeButton.setVisibility(View.GONE);
+ animateShowSwitcher(selectedTabItem, duration, interpolator,
+ createZoomOutAnimationListener(selectedTabItem, peekAnimation));
+ }
+
+ /**
+ * Creates and returns a layout listener, which allows to animate the position and size of a tab
+ * in order to show the tab switcher, once its view has been inflated.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, whose view should be animated, as an
+ * instance of the class {@link TabItem}. The tab item may not be null
+ * @return The listener, which has been created, as an instance of the type {@link
+ * OnGlobalLayoutListener}. The listener may not be null
+ */
+ @NonNull
+ private OnGlobalLayoutListener createShowSwitcherLayoutListener(
+ @NonNull final TabItem tabItem) {
+ return new OnGlobalLayoutListener() {
+
+ @Override
+ public void onGlobalLayout() {
+ animateShowSwitcher(tabItem, createUpdateViewAnimationListener(tabItem));
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns a layout listener, which allows to animate the position and size of a tab
+ * in order to hide the tab switcher, once its view has been inflated.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, whose view should be animated, as an
+ * instance of the class {@link TabItem}. The tab item may not be null
+ * @return The listener, which has been created, as an instance of the type {@link
+ * OnGlobalLayoutListener}. The listener may not be null
+ */
+ @NonNull
+ private OnGlobalLayoutListener createHideSwitcherLayoutListener(
+ @NonNull final TabItem tabItem) {
+ return new OnGlobalLayoutListener() {
+
+ @Override
+ public void onGlobalLayout() {
+ animateHideSwitcher(tabItem,
+ tabItem.getIndex() == getModel().getSelectedTabIndex() ?
+ createHideSwitcherAnimationListener() : null);
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns a layout listener, which allows to remove a tab, once its view has been
+ * inflated.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, which should be removed, as an instance
+ * of the class {@link TabItem}. The tab item may not be null
+ * @param swipeAnimation
+ * The animation, which should be used, as an instance of the class {@link
+ * SwipeAnimation}. The animation may not be null
+ * @return The listener, which has been created, as an instance of the type {@link
+ * OnGlobalLayoutListener}. The listener may not be null
+ */
+ @NonNull
+ private OnGlobalLayoutListener createRemoveLayoutListener(@NonNull final TabItem tabItem,
+ @NonNull final SwipeAnimation swipeAnimation) {
+ return new OnGlobalLayoutListener() {
+
+ @Override
+ public void onGlobalLayout() {
+ animateRemove(tabItem, swipeAnimation);
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns a layout listener, which allows to relocate a tab, once its view has been
+ * inflated.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, which should be relocated, as an instance
+ * of the class {@link TabItem}. The tab item may not be null
+ * @param position
+ * The position, the tab should be relocated to, in pixels as a {@link Float} value
+ * @param tag
+ * The tag, which should be applied to the given tab item, as an instance of the class
+ * {@link Tag} or null, if no tag should be applied
+ * @param delay
+ * The delay of the relocate animation in milliseconds as a {@link Long} value
+ * @param listener
+ * The listener, which should be notified about the progress of the relocate animation,
+ * as an instance of the type {@link AnimatorListener} or null, if no listener should be
+ * notified
+ * @return The listener, which has been created, as an instance of the class {@link
+ * OnGlobalLayoutListener}. The listener may not be null
+ */
+ @NonNull
+ private OnGlobalLayoutListener createRelocateLayoutListener(@NonNull final TabItem tabItem,
+ final float position,
+ @Nullable final Tag tag,
+ final long delay,
+ @Nullable final AnimatorListener listener) {
+ return new OnGlobalLayoutListener() {
+
+ @Override
+ public void onGlobalLayout() {
+ animateRelocate(tabItem, position, tag, delay, listener);
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns a layout listener, which allows to show a tab as the currently selected
+ * one, once it view has been inflated.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, which has been added, as an instance of
+ * the class {@link TabItem}. The tab item may not be null
+ * @return The listener, which has been created, as an instance of the type {@link
+ * OnGlobalLayoutListener}. The listener may not be null
+ */
+ @NonNull
+ private OnGlobalLayoutListener createAddSelectedTabLayoutListener(
+ @NonNull final TabItem tabItem) {
+ return new OnGlobalLayoutListener() {
+
+ @Override
+ public void onGlobalLayout() {
+ View view = tabItem.getView();
+ FrameLayout.LayoutParams layoutParams =
+ (FrameLayout.LayoutParams) view.getLayoutParams();
+ view.setAlpha(1f);
+ getArithmetics().setPivot(Axis.DRAGGING_AXIS, view,
+ getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE));
+ getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view,
+ getArithmetics().getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.NONE));
+ view.setX(layoutParams.leftMargin);
+ view.setY(layoutParams.topMargin);
+ getArithmetics().setScale(Axis.DRAGGING_AXIS, view, 1);
+ getArithmetics().setScale(Axis.ORTHOGONAL_AXIS, view, 1);
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns a layout listener, which allows to start a reveal animation to add a tab,
+ * once its view has been inflated.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, which should be added, as an instance of
+ * the class {@link TabItem}. The tab item may not be null
+ * @param revealAnimation
+ * The reveal animation, which should be started, as an instance of the class {@link
+ * RevealAnimation}. The reveal animation may not be null
+ * @return The listener, which has been created, as an instance of the type {@link
+ * OnGlobalLayoutListener}. The listener may not be null
+ */
+ @NonNull
+ private OnGlobalLayoutListener createRevealLayoutListener(@NonNull final TabItem tabItem,
+ @NonNull final RevealAnimation revealAnimation) {
+ return new OnGlobalLayoutListener() {
+
+ @Override
+ public void onGlobalLayout() {
+ View view = tabItem.getView();
+ float x = revealAnimation.getX();
+ float y = revealAnimation.getY() + tabTitleContainerHeight;
+ FrameLayout.LayoutParams layoutParams =
+ (FrameLayout.LayoutParams) view.getLayoutParams();
+ view.setAlpha(1f);
+ getArithmetics().setPivot(Axis.X_AXIS, view, x);
+ getArithmetics().setPivot(Axis.Y_AXIS, view, y);
+ view.setX(layoutParams.leftMargin);
+ view.setY(layoutParams.topMargin);
+ getArithmetics().setScale(Axis.DRAGGING_AXIS, view, 0);
+ getArithmetics().setScale(Axis.ORTHOGONAL_AXIS, view, 0);
+ animateReveal(tabItem, revealAnimation);
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns a layout listener, which allows to start a peek animation to add a tab,
+ * once its view has been inflated.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, which should be added, as an instance of
+ * the class {@link TabItem}. The tab item may not be null
+ * @param peekAnimation
+ * The peek animation, which should be started, as an instance of the class {@link
+ * PeekAnimation}. The peek animation may not be null
+ * @return The listener, which has been created, as an instance of the type {@link
+ * OnGlobalLayoutListener}. The listener may not be null
+ */
+ private OnGlobalLayoutListener createPeekLayoutListener(@NonNull final TabItem tabItem,
+ @NonNull final PeekAnimation peekAnimation) {
+ return new OnGlobalLayoutListener() {
+
+ @Override
+ public void onGlobalLayout() {
+ long totalDuration =
+ peekAnimation.getDuration() != -1 ? peekAnimation.getDuration() :
+ peekAnimationDuration;
+ long duration = totalDuration / 3;
+ Interpolator interpolator =
+ peekAnimation.getInterpolator() != null ? peekAnimation.getInterpolator() :
+ new AccelerateDecelerateInterpolator();
+ float peekPosition =
+ getArithmetics().getTabContainerSize(Axis.DRAGGING_AXIS, false) * 0.66f;
+ animatePeek(tabItem, duration, interpolator, peekPosition, peekAnimation);
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns a layout listener, which allows to start a swipe animations to add
+ * several tabs, once their views have been inflated.
+ *
+ * @param addedTabItems
+ * An array, which contains the tab items, which correspond to the tabs, which should be
+ * added, as an array of the type {@link TabItem}. The array may not be null
+ * @param swipeAnimation
+ * The swipe animation, which should be started, as an instance of the class {@link
+ * SwipeAnimation}. The swipe animation may not be null
+ * @return The listener, which has been created, as an instance of the type {@link
+ * OnGlobalLayoutListener}. The listener may not be null
+ */
+ @NonNull
+ private OnGlobalLayoutListener createSwipeLayoutListener(@NonNull final TabItem[] addedTabItems,
+ @NonNull final SwipeAnimation swipeAnimation) {
+ return new OnGlobalLayoutListener() {
+
+ @Override
+ public void onGlobalLayout() {
+ int count = getModel().getCount();
+ float previousAttachedPosition =
+ calculateAttachedPosition(count - addedTabItems.length);
+ float attachedPosition = calculateAttachedPosition(count);
+ TabItem[] tabItems;
+
+ if (count - addedTabItems.length == 0) {
+ tabItems = calculateInitialTabItems(-1, -1);
+ } else {
+ TabItem firstAddedTabItem = addedTabItems[0];
+ int index = firstAddedTabItem.getIndex();
+ boolean isReferencingPredecessor = index > 0;
+ int referenceIndex = isReferencingPredecessor ? index - 1 :
+ (index + addedTabItems.length - 1 < count - 1 ?
+ index + addedTabItems.length : -1);
+ TabItem referenceTabItem = referenceIndex != -1 ?
+ TabItem.create(getTabSwitcher(), viewRecycler, referenceIndex) : null;
+ State state =
+ referenceTabItem != null ? referenceTabItem.getTag().getState() : null;
+
+ if (state == null || state == State.STACKED_START) {
+ tabItems = relocateWhenAddingStackedTabs(true, addedTabItems);
+ } else if (state == State.STACKED_END) {
+ tabItems = relocateWhenAddingStackedTabs(false, addedTabItems);
+ } else if (state == State.FLOATING ||
+ (state == State.STACKED_START_ATOP && (index > 0 || count <= 2))) {
+ tabItems = relocateWhenAddingFloatingTabs(addedTabItems, referenceTabItem,
+ isReferencingPredecessor, attachedPosition,
+ attachedPosition != previousAttachedPosition);
+ } else {
+ tabItems = relocateWhenAddingHiddenTabs(addedTabItems, referenceTabItem);
+ }
+ }
+
+ Tag previousTag = null;
+
+ for (TabItem tabItem : tabItems) {
+ Tag tag = tabItem.getTag();
+
+ if (previousTag == null || tag.getPosition() != previousTag.getPosition()) {
+ createBottomMarginLayoutListener(tabItem).onGlobalLayout();
+ View view = tabItem.getView();
+ view.setTag(R.id.tag_properties, tag);
+ view.setAlpha(swipedTabAlpha);
+ float swipePosition = calculateSwipePosition();
+ float scale = getArithmetics().getScale(view, true);
+ getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, getArithmetics()
+ .getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE));
+ getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, getArithmetics()
+ .getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.NONE));
+ getArithmetics().setPosition(Axis.DRAGGING_AXIS, view, tag.getPosition());
+ getArithmetics().setPosition(Axis.ORTHOGONAL_AXIS, view,
+ swipeAnimation.getDirection() == SwipeDirection.LEFT ?
+ -1 * swipePosition : swipePosition);
+ getArithmetics().setScale(Axis.DRAGGING_AXIS, view, scale);
+ getArithmetics().setScale(Axis.ORTHOGONAL_AXIS, view, scale);
+ getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, getArithmetics()
+ .getPivot(Axis.DRAGGING_AXIS, view, DragState.SWIPE));
+ getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, getArithmetics()
+ .getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.SWIPE));
+ getArithmetics().setScale(Axis.DRAGGING_AXIS, view, swipedTabScale * scale);
+ getArithmetics()
+ .setScale(Axis.ORTHOGONAL_AXIS, view, swipedTabScale * scale);
+ animateSwipe(tabItem, false, 0, swipeAnimation,
+ createSwipeAnimationListener(tabItem));
+ } else {
+ viewRecycler.remove(tabItem);
+ }
+
+ previousTag = tag;
+ }
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns a layout listener, which allows to adapt the bottom margin of a tab, once
+ * its view has been inflated.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, whose view should be adapted, as an
+ * instance of the class {@link TabItem}. The tab item may not be null
+ * @return The layout listener, which has been created, as an instance of the type {@link
+ * OnGlobalLayoutListener}. The layout listener may not be null
+ */
+ private OnGlobalLayoutListener createBottomMarginLayoutListener(
+ @NonNull final TabItem tabItem) {
+ return new OnGlobalLayoutListener() {
+
+ @Override
+ public void onGlobalLayout() {
+ View view = tabItem.getView();
+
+ if (tabViewBottomMargin == -1) {
+ tabViewBottomMargin = calculateBottomMargin(view);
+ }
+
+ FrameLayout.LayoutParams layoutParams =
+ (FrameLayout.LayoutParams) view.getLayoutParams();
+ layoutParams.bottomMargin = tabViewBottomMargin;
+ view.setLayoutParams(layoutParams);
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns a layout listener, which allows to adapt the size and position of a tab,
+ * once its view has been inflated.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, whose view should be adapted, as an
+ * instance of the class {@link TabItem}. The tab item may not be null
+ * @param layoutListener
+ * The layout lister, which should be notified, when the created listener is invoked, as
+ * an instance of the type {@link OnGlobalLayoutListener} or null, if no listener should
+ * be notified
+ * @return The layout listener, which has been created, as an instance of the type {@link
+ * OnGlobalLayoutListener}. The layout listener may not be null
+ */
+ @NonNull
+ private OnGlobalLayoutListener createInflateViewLayoutListener(@NonNull final TabItem tabItem,
+ @Nullable final OnGlobalLayoutListener layoutListener) {
+ return new OnGlobalLayoutListener() {
+
+ @Override
+ public void onGlobalLayout() {
+ adaptViewSize(tabItem);
+ updateView(tabItem);
+
+ if (layoutListener != null) {
+ layoutListener.onGlobalLayout();
+ }
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns an animation listener, which allows to update the view, which is used to
+ * visualize a specific tab, when an animation has been finished.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, whose view should be updated, as an
+ * instance of the class {@link TabItem}. The tab item may not be null
+ * @return The animation listener, which has been created, as an instance of the type {@link
+ * AnimatorListener}. The listener may not be null
+ */
+ @NonNull
+ private AnimatorListener createUpdateViewAnimationListener(@NonNull final TabItem tabItem) {
+ return new AnimatorListenerAdapter() {
+
+ @Override
+ public void onAnimationEnd(final Animator animation) {
+ super.onAnimationEnd(animation);
+ inflateOrRemoveView(tabItem);
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns an animation listener, which allows to inflate or remove the views, which
+ * are used to visualize tabs, when an animation, which is used to hide the tab switcher,
+ * has been finished.
+ *
+ * @return The animation listener, which has been created, as an instance of the type {@link
+ * AnimatorListener}. The listener may not be null
+ */
+ @NonNull
+ private AnimatorListener createHideSwitcherAnimationListener() {
+ return new AnimatorListenerAdapter() {
+
+ @Override
+ public void onAnimationEnd(final Animator animation) {
+ super.onAnimationEnd(animation);
+ AbstractTabItemIterator iterator =
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create();
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ if (tabItem.getTab() == getModel().getSelectedTab()) {
+ Pair pair = viewRecycler.inflate(tabItem);
+ View view = pair.first;
+ FrameLayout.LayoutParams layoutParams =
+ (FrameLayout.LayoutParams) view.getLayoutParams();
+ view.setAlpha(1f);
+ getArithmetics().setScale(Axis.DRAGGING_AXIS, view, 1);
+ getArithmetics().setScale(Axis.ORTHOGONAL_AXIS, view, 1);
+ view.setX(layoutParams.leftMargin);
+ view.setY(layoutParams.topMargin);
+ } else {
+ viewRecycler.remove(tabItem);
+ }
+ }
+
+ viewRecycler.clearCache();
+ recyclerAdapter.clearCachedPreviews();
+ tabViewBottomMargin = -1;
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns an animation listener, which allows to remove all tabs, when the
+ * animation, which is used to swipe all tabs, has been finished.
+ *
+ * @return The animation listener, which has been created, as an instance of the type {@link
+ * AnimatorListener}. The listener may not be null
+ */
+ @NonNull
+ private AnimatorListener createClearAnimationListener() {
+ return new AnimatorListenerAdapter() {
+
+ @Override
+ public void onAnimationEnd(final Animator animation) {
+ super.onAnimationEnd(animation);
+ viewRecycler.removeAll();
+ animateToolbarVisibility(getModel().areToolbarsShown(), 0);
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns a listener, which allows to handle, when a tab has been swiped, but was
+ * not removed.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, which has been swiped, as an instance of
+ * the class {@link TabItem}. The tab item may not be null
+ * @return The listener, which has been created, as an instance of the type {@link
+ * AnimatorListener}. The listener may not be null
+ */
+ @NonNull
+ private AnimatorListener createSwipeAnimationListener(@NonNull final TabItem tabItem) {
+ return new AnimatorListenerAdapter() {
+
+ @Override
+ public void onAnimationEnd(final Animator animation) {
+ super.onAnimationEnd(animation);
+ inflateOrRemoveView(tabItem);
+ View view = tabItem.getView();
+ adaptStackOnSwipeAborted(tabItem, tabItem.getIndex() + 1);
+ tabItem.getTag().setClosing(false);
+ getArithmetics().setPivot(Axis.DRAGGING_AXIS, view,
+ getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE));
+ animateToolbarVisibility(true, 0);
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns a listener, which allows to relocate all previous tabs, when a tab has
+ * been removed.
+ *
+ * @param removedTabItem
+ * The tab item, which corresponds to the tab, which has been removed, as an instance of
+ * the class {@link TabItem}. The tab item may not be null
+ * @return The listener, which has been created, as an instance of the type {@link
+ * AnimatorListener}. The listener may not be null
+ */
+ @NonNull
+ private AnimatorListener createRemoveAnimationListener(@NonNull final TabItem removedTabItem) {
+ return new AnimatorListenerAdapter() {
+
+ @Override
+ public void onAnimationStart(final Animator animation) {
+ super.onAnimationStart(animation);
+
+ if (getModel().isEmpty()) {
+ animateToolbarVisibility(getModel().areToolbarsShown(), 0);
+ }
+
+ float previousAttachedPosition =
+ calculateAttachedPosition(getModel().getCount() + 1);
+ float attachedPosition = calculateAttachedPosition(getModel().getCount());
+ State state = removedTabItem.getTag().getState();
+
+ if (state == State.STACKED_END) {
+ relocateWhenRemovingStackedTab(removedTabItem, false);
+ } else if (state == State.STACKED_START) {
+ relocateWhenRemovingStackedTab(removedTabItem, true);
+ } else if (state == State.FLOATING || state == State.STACKED_START_ATOP) {
+ relocateWhenRemovingFloatingTab(removedTabItem, attachedPosition,
+ previousAttachedPosition != attachedPosition);
+ }
+ }
+
+ @Override
+ public void onAnimationEnd(final Animator animation) {
+ super.onAnimationEnd(animation);
+ viewRecycler.remove(removedTabItem);
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns an animation listener, which allows to update or remove the view, which
+ * is used to visualize a tab, when the animation, which has been used to relocate it, has been
+ * ended.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, which has been relocated, as an instance
+ * of the class {@link TabItem}. The tab item may not be null
+ * @return The listener, which has been created, as an instance of the type {@link
+ * AnimatorListener}. The listener may not be null
+ */
+ @NonNull
+ private AnimatorListener createRelocateAnimationListener(@NonNull final TabItem tabItem) {
+ return new AnimatorListenerAdapter() {
+
+ @Override
+ public void onAnimationStart(final Animator animation) {
+ super.onAnimationStart(animation);
+ tabItem.getView().setVisibility(View.VISIBLE);
+ }
+
+ @Override
+ public void onAnimationEnd(final Animator animation) {
+ super.onAnimationEnd(animation);
+
+ if (tabItem.getTag().getState() == State.STACKED_START_ATOP) {
+ adaptStackOnSwipeAborted(tabItem, tabItem.getIndex() + 1);
+ }
+
+ if (tabItem.isVisible()) {
+ updateView(tabItem);
+ } else {
+ viewRecycler.remove(tabItem);
+ }
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns an animation listener, which allows to adapt the pivot of a specific
+ * view, when an animation, which reverted an overshoot, has been ended.
+ *
+ * @param view
+ * The view, whose pivot should be adapted, as an instance of the class {@link View}.
+ * The view may not be null
+ * @param listener
+ * The listener, which should be notified about the animation's progress, as an instance
+ * of the type {@link AnimatorListener} or null, if no listener should be notified
+ * @return The listener, which has been created, as an instance of the type {@link
+ * AnimatorListener}. The listener may not be null
+ */
+ @NonNull
+ private AnimatorListener createRevertOvershootAnimationListener(@NonNull final View view,
+ @Nullable final AnimatorListener listener) {
+ return new AnimatorListenerAdapter() {
+
+ @Override
+ public void onAnimationEnd(final Animator animation) {
+ super.onAnimationEnd(animation);
+ getArithmetics().setPivot(Axis.DRAGGING_AXIS, view,
+ getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE));
+ getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view,
+ getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE));
+
+ if (listener != null) {
+ listener.onAnimationEnd(animation);
+ }
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns an animation listener, which allows to revert an overshoot at the start,
+ * when an animation has been ended.
+ *
+ * @return The listener, which has been created, as an instance of the type {@link
+ * AnimatorListener}. The listener may not be null
+ */
+ @NonNull
+ private AnimatorListener createRevertStartOvershootAnimationListener() {
+ return new AnimatorListenerAdapter() {
+
+ @Override
+ public void onAnimationEnd(final Animator animation) {
+ super.onAnimationEnd(animation);
+ animateRevertStartOvershoot(new DecelerateInterpolator());
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns an animation listener, which allows to hide a tab, which has been added
+ * by using a peek animation, when the animation has been ended.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, which has been added by using the peek
+ * animation, as an instance of the class {@link TabItem}. The tab item may not be null
+ * @param peekAnimation
+ * The peek animation as an instance of the class {@link PeekAnimation}. The peek
+ * animation may not be null
+ * @return The listener, which has been created, as an instance of the type {@link
+ * AnimatorListener}. The listener may not be null
+ */
+ @NonNull
+ private AnimatorListener createPeekAnimationListener(@NonNull final TabItem tabItem,
+ @NonNull final PeekAnimation peekAnimation) {
+ return new AnimatorListenerAdapter() {
+
+ @Override
+ public void onAnimationEnd(final Animator animation) {
+ super.onAnimationEnd(animation);
+ long totalDuration =
+ peekAnimation.getDuration() != -1 ? peekAnimation.getDuration() :
+ peekAnimationDuration;
+ long duration = totalDuration / 3;
+ Interpolator interpolator =
+ peekAnimation.getInterpolator() != null ? peekAnimation.getInterpolator() :
+ new AccelerateDecelerateInterpolator();
+ View view = tabItem.getView();
+ getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, tabTitleContainerHeight);
+ getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view,
+ getArithmetics().getSize(Axis.ORTHOGONAL_AXIS, view) / 2f);
+ ViewPropertyAnimator animator = view.animate();
+ animator.setDuration(duration);
+ animator.setStartDelay(duration);
+ animator.setInterpolator(interpolator);
+ animator.setListener(
+ new AnimationListenerWrapper(createRevertPeekAnimationListener(tabItem)));
+ animator.alpha(0);
+ getArithmetics().animatePosition(Axis.DRAGGING_AXIS, animator, view,
+ getArithmetics().getPosition(Axis.DRAGGING_AXIS, view) * 1.5f, false);
+ getArithmetics().animateScale(Axis.DRAGGING_AXIS, animator, 0);
+ getArithmetics().animateScale(Axis.ORTHOGONAL_AXIS, animator, 0);
+ animator.start();
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns an animation listener, which allows to remove the view of a tab, which
+ * has been added by using a peek animation, when the animation, which reverts the peek
+ * animation, has been ended.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, which has been added by using the peek
+ * animation, as an instance of the class {@link TabItem}. The tab item may not be null
+ * @return The listener, which has been created, as an instance of the type {@link
+ * AnimatorListener}. The listener may not be null
+ */
+ @NonNull
+ private AnimatorListener createRevertPeekAnimationListener(@NonNull final TabItem tabItem) {
+ return new AnimatorListenerAdapter() {
+
+ @Override
+ public void onAnimationEnd(final Animator animation) {
+ super.onAnimationEnd(animation);
+ viewRecycler.remove(tabItem);
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns an animation listener, which allows to zoom in the currently selected
+ * tab, when a peek animation has been ended.
+ *
+ * @param selectedTabItem
+ * The tab item, which corresponds to the currently selected tab, as an instance of the
+ * class {@link TabItem}. The tab item may not be null
+ * @param peekAnimation
+ * The peek animation as an instance of the class {@link PeekAnimation}. The peek
+ * animation may not be null
+ * @return The listener, which has been created, as an instance of the type {@link
+ * AnimatorListener}. The listener may not be null
+ */
+ @NonNull
+ private AnimatorListener createZoomOutAnimationListener(@NonNull final TabItem selectedTabItem,
+ @NonNull final PeekAnimation peekAnimation) {
+ return new AnimatorListenerAdapter() {
+
+ @Override
+ public void onAnimationEnd(final Animator animation) {
+ super.onAnimationEnd(animation);
+ getModel().removeListener(PhoneTabSwitcherLayout.this);
+ getModel().hideSwitcher();
+ long totalDuration =
+ peekAnimation.getDuration() != -1 ? peekAnimation.getDuration() :
+ peekAnimationDuration;
+ long duration = totalDuration / 3;
+ Interpolator interpolator =
+ peekAnimation.getInterpolator() != null ? peekAnimation.getInterpolator() :
+ new AccelerateDecelerateInterpolator();
+ animateHideSwitcher(selectedTabItem, duration, interpolator, duration,
+ createZoomInAnimationListener(selectedTabItem));
+ }
+
+ };
+ }
+
+ /**
+ * Creates and returns an animation listener, which allows to restore the original state of a
+ * tab, when an animation, which zooms in the tab, has been ended.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, which has been zoomed in, as an instance
+ * of the class {@link TabItem}. The tab item may not be null
+ * @return The listener, which has been created, as an instance of the type {@link
+ * AnimatorListener}. The listener may not be null
+ */
+ private AnimatorListener createZoomInAnimationListener(@NonNull final TabItem tabItem) {
+ return new AnimatorListenerAdapter() {
+
+ @Override
+ public void onAnimationEnd(final Animator animation) {
+ super.onAnimationEnd(animation);
+ getModel().addListener(PhoneTabSwitcherLayout.this);
+ viewRecycler.inflate(tabItem);
+ viewRecycler.clearCache();
+ recyclerAdapter.clearCachedPreviews();
+ tabViewBottomMargin = -1;
+ }
+
+ };
+ }
+
+ /**
+ * Adapts the stack, which is located at the start, when swiping a tab.
+ *
+ * @param swipedTabItem
+ * The tab item, which corresponds to the swiped tab, as an instance of the class {@link
+ * TabItem}. The tab item may not be null
+ * @param successorIndex
+ * The index of the tab, which is located after the swiped tab, as an {@link Integer}
+ * value
+ * @param count
+ * The number of tabs, which are contained by the tab switcher, excluding the swiped
+ * tab, as an {@link Integer} value
+ */
+ private void adaptStackOnSwipe(@NonNull final TabItem swipedTabItem, final int successorIndex,
+ final int count) {
+ if (swipedTabItem.getTag().getState() == State.STACKED_START_ATOP &&
+ successorIndex < getModel().getCount()) {
+ TabItem tabItem = TabItem.create(getTabSwitcher(), viewRecycler, successorIndex);
+ State state = tabItem.getTag().getState();
+
+ if (state == State.HIDDEN || state == State.STACKED_START) {
+ Pair pair =
+ calculatePositionAndStateWhenStackedAtStart(count, swipedTabItem.getIndex(),
+ (TabItem) null);
+ tabItem.getTag().setPosition(pair.first);
+ tabItem.getTag().setState(pair.second);
+ inflateOrRemoveView(tabItem);
+ }
+ }
+ }
+
+ /**
+ * Adapts the stack, which located at the start, when swiping a tab has been aborted.
+ *
+ * @param swipedTabItem
+ * The tab item, which corresponds to the swiped tab, as an instance of the class {@link
+ * TabItem}. The tab item may not be null
+ * @param successorIndex
+ * The index of the the tab, which is located after the swiped tab, as an {@link
+ * Integer} value
+ */
+ private void adaptStackOnSwipeAborted(@NonNull final TabItem swipedTabItem,
+ final int successorIndex) {
+ if (swipedTabItem.getTag().getState() == State.STACKED_START_ATOP &&
+ successorIndex < getModel().getCount()) {
+ TabItem tabItem = TabItem.create(getTabSwitcher(), viewRecycler, successorIndex);
+
+ if (tabItem.getTag().getState() == State.STACKED_START_ATOP) {
+ Pair pair =
+ calculatePositionAndStateWhenStackedAtStart(getTabSwitcher().getCount(),
+ tabItem.getIndex(), swipedTabItem);
+ tabItem.getTag().setPosition(pair.first);
+ tabItem.getTag().setState(pair.second);
+ inflateOrRemoveView(tabItem);
+ }
+ }
+ }
+
+ /**
+ * Inflates or removes the view, which is used to visualize a specific tab, depending on the
+ * tab's current state.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, whose view should be inflated or removed,
+ * as an instance of the class {@link TabItem}. The tab item may not be null
+ */
+ private void inflateOrRemoveView(@NonNull final TabItem tabItem) {
+ if (tabItem.isInflated() && !tabItem.isVisible()) {
+ viewRecycler.remove(tabItem);
+ } else if (tabItem.isVisible()) {
+ if (!tabItem.isInflated()) {
+ inflateAndUpdateView(tabItem, null);
+ } else {
+ updateView(tabItem);
+ }
+ }
+ }
+
+ /**
+ * Inflates and updates the view, which is used to visualize a specific tab.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, whose view should be inflated, as an
+ * instance of the class {@link TabItem}. The tab item may not be null
+ * @param listener
+ * The layout listener, which should be notified, when the view has been inflated, as an
+ * instance of the type {@link OnGlobalLayoutListener} or null, if no listener should be
+ * notified
+ */
+ private void inflateAndUpdateView(@NonNull final TabItem tabItem,
+ @Nullable final OnGlobalLayoutListener listener) {
+ inflateView(tabItem, createInflateViewLayoutListener(tabItem, listener),
+ tabViewBottomMargin);
+ }
+
+ /**
+ * Inflates the view, which is used to visualize a specific tab.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, whose view should be inflated, as an
+ * instance of the class {@link TabItem}. The tab item may not be null
+ * @param listener
+ * The layout listener, which should be notified, when the view has been inflated, as an
+ * instance of the type {@link OnGlobalLayoutListener} or null, if no listener should be
+ * notified
+ * @param params
+ * An array, which contains optional parameters, which should be passed to the view
+ * recycler, which is used to inflate the view, as an array of the type {@link Integer}.
+ * The array may not be null
+ */
+ private void inflateView(@NonNull final TabItem tabItem,
+ @Nullable final OnGlobalLayoutListener listener,
+ @NonNull final Integer... params) {
+ Pair pair = viewRecycler.inflate(tabItem, params);
+
+ if (listener != null) {
+ boolean inflated = pair.second;
+
+ if (inflated) {
+ View view = pair.first;
+ view.getViewTreeObserver()
+ .addOnGlobalLayoutListener(new LayoutListenerWrapper(view, listener));
+ } else {
+ listener.onGlobalLayout();
+ }
+ }
+ }
+
+ /**
+ * Adapts the size of the view, which is used to visualize a specific tab.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, whose view should be adapted, as an
+ * instance of the class {@link TabItem}. The tab item may not be null
+ */
+ private void adaptViewSize(@NonNull final TabItem tabItem) {
+ View view = tabItem.getView();
+ getArithmetics().setPivot(Axis.DRAGGING_AXIS, view,
+ getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE));
+ getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view,
+ getArithmetics().getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.NONE));
+ float scale = getArithmetics().getScale(view, true);
+ getArithmetics().setScale(Axis.DRAGGING_AXIS, view, scale);
+ getArithmetics().setScale(Axis.ORTHOGONAL_AXIS, view, scale);
+ }
+
+ /**
+ * Updates the view, which is used to visualize a specific tab.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, whose view should be updated, as an
+ * instance of the class {@link TabItem}. The tab item may not be null
+ */
+ private void updateView(@NonNull final TabItem tabItem) {
+ float position = tabItem.getTag().getPosition();
+ View view = tabItem.getView();
+ view.setAlpha(1f);
+ view.setVisibility(View.VISIBLE);
+ getArithmetics().setPivot(Axis.DRAGGING_AXIS, view,
+ getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE));
+ getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view,
+ getArithmetics().getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.NONE));
+ getArithmetics().setPosition(Axis.DRAGGING_AXIS, view, position);
+ getArithmetics().setPosition(Axis.ORTHOGONAL_AXIS, view, 0);
+ getArithmetics().setRotation(Axis.ORTHOGONAL_AXIS, view, 0);
+ }
+
+ /**
+ * Relocates all previous tabs, when a floating tab has been removed from the tab switcher.
+ *
+ * @param removedTabItem
+ * The tab item, which corresponds to the tab, which has been removed, as an instance of
+ * the class {@link TabItem}. The tab item may not be null
+ * @param attachedPositionChanged
+ * True, if removing the tab caused the attached position to be changed, false
+ * otherwise
+ */
+ private void relocateWhenRemovingFloatingTab(@NonNull final TabItem removedTabItem,
+ final float attachedPosition,
+ boolean attachedPositionChanged) {
+ AbstractTabItemIterator iterator;
+ TabItem tabItem;
+ float defaultTabSpacing = calculateMaxTabSpacing(getModel().getCount(), null);
+ float minTabSpacing = calculateMinTabSpacing(getModel().getCount());
+ int referenceIndex = removedTabItem.getIndex();
+ TabItem currentReferenceTabItem = removedTabItem;
+ float referencePosition = removedTabItem.getTag().getPosition();
+
+ if (attachedPositionChanged && getModel().getCount() > 0) {
+ int neighboringIndex =
+ removedTabItem.getIndex() > 0 ? referenceIndex - 1 : referenceIndex;
+ referencePosition += Math.abs(
+ TabItem.create(getTabSwitcher(), viewRecycler, neighboringIndex).getTag()
+ .getPosition() - referencePosition) / 2f;
+ }
+
+ referencePosition =
+ Math.min(calculateEndPosition(removedTabItem.getIndex() - 1), referencePosition);
+ float initialReferencePosition = referencePosition;
+
+ if (removedTabItem.getIndex() > 0) {
+ int selectedTabIndex = getModel().getSelectedTabIndex();
+ TabItem selectedTabItem =
+ TabItem.create(getTabSwitcher(), viewRecycler, selectedTabIndex);
+ float maxTabSpacing = calculateMaxTabSpacing(getModel().getCount(), selectedTabItem);
+ iterator = new TabItemIterator.Builder(getTabSwitcher(), viewRecycler)
+ .start(removedTabItem.getIndex() - 1).reverse(true).create();
+
+ while ((tabItem = iterator.next()) != null) {
+ TabItem predecessor = iterator.peek();
+ float currentTabSpacing =
+ calculateMaxTabSpacing(getModel().getCount(), currentReferenceTabItem);
+ Pair pair;
+
+ if (tabItem.getIndex() == removedTabItem.getIndex() - 1) {
+ pair = clipTabPosition(getModel().getCount(), tabItem.getIndex(),
+ referencePosition, predecessor);
+ currentReferenceTabItem = tabItem;
+ referencePosition = pair.first;
+ referenceIndex = tabItem.getIndex();
+ } else if (referencePosition >= attachedPosition - currentTabSpacing) {
+ float position;
+
+ if (selectedTabIndex > tabItem.getIndex() &&
+ selectedTabIndex <= referenceIndex) {
+ position = referencePosition + maxTabSpacing +
+ ((referenceIndex - tabItem.getIndex() - 1) * defaultTabSpacing);
+ } else {
+ position = referencePosition +
+ ((referenceIndex - tabItem.getIndex()) * defaultTabSpacing);
+ }
+
+ pair = clipTabPosition(getModel().getCount(), tabItem.getIndex(), position,
+ predecessor);
+ } else {
+ TabItem successor = iterator.previous();
+ float successorPosition = successor.getTag().getPosition();
+ float position = (attachedPosition * (successorPosition + minTabSpacing)) /
+ (minTabSpacing + attachedPosition - currentTabSpacing);
+ pair = clipTabPosition(getModel().getCount(), tabItem.getIndex(), position,
+ predecessor);
+
+ if (pair.first >= attachedPosition - currentTabSpacing) {
+ currentReferenceTabItem = tabItem;
+ referencePosition = pair.first;
+ referenceIndex = tabItem.getIndex();
+ }
+ }
+
+ Tag tag = tabItem.getTag().clone();
+ tag.setPosition(pair.first);
+ tag.setState(pair.second);
+
+ if (tag.getState() != State.HIDDEN) {
+ long startDelay = Math.abs(removedTabItem.getIndex() - tabItem.getIndex()) *
+ relocateAnimationDelay;
+
+ if (!tabItem.isInflated()) {
+ Pair pair2 =
+ calculatePositionAndStateWhenStackedAtEnd(tabItem.getIndex());
+ tabItem.getTag().setPosition(pair2.first);
+ tabItem.getTag().setState(pair2.second);
+ }
+
+ relocate(tabItem, tag.getPosition(), tag, startDelay);
+ } else {
+ break;
+ }
+ }
+ }
+
+ if (attachedPositionChanged && getModel().getCount() > 2 &&
+ removedTabItem.getTag().getState() != State.STACKED_START_ATOP) {
+ iterator = new TabItemIterator.Builder(getTabSwitcher(), viewRecycler)
+ .start(removedTabItem.getIndex()).create();
+ float previousPosition = initialReferencePosition;
+ Tag previousTag = removedTabItem.getTag();
+
+ while ((tabItem = iterator.next()) != null &&
+ tabItem.getIndex() < getModel().getCount() - 1) {
+ float position = calculateNonLinearPosition(previousPosition,
+ calculateMaxTabSpacing(getModel().getCount(), tabItem));
+ Pair pair =
+ clipTabPosition(getModel().getCount(), tabItem.getIndex(), position,
+ previousTag.getState());
+ Tag tag = tabItem.getTag().clone();
+ tag.setPosition(pair.first);
+ tag.setState(pair.second);
+ long startDelay = (Math.abs(removedTabItem.getIndex() - tabItem.getIndex()) + 1) *
+ relocateAnimationDelay;
+
+ if (!tabItem.isInflated()) {
+ Pair pair2 =
+ calculatePositionAndStateWhenStackedAtStart(getModel().getCount(),
+ tabItem.getIndex(), iterator.previous());
+ tabItem.getTag().setPosition(pair2.first);
+ tabItem.getTag().setState(pair2.second);
+ }
+
+ relocate(tabItem, tag.getPosition(), tag, startDelay);
+ previousPosition = pair.first;
+ previousTag = tag;
+
+ if (pair.second == State.HIDDEN || pair.second == State.STACKED_START) {
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Relocates all neighboring tabs, when a stacked tab has been removed from the tab switcher.
+ *
+ * @param removedTabItem
+ * The tab item, which corresponds to the tab, which has been removed, as an instance of
+ * the class {@link TabItem}. The tab item may not be null
+ * @param start
+ * True, if the removed tab was part of the stack, which is located at the start, false,
+ * if it was part of the stack, which is located at the end
+ */
+ private void relocateWhenRemovingStackedTab(@NonNull final TabItem removedTabItem,
+ final boolean start) {
+ int startIndex = removedTabItem.getIndex() + (start ? -1 : 0);
+ TabItemIterator iterator =
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).reverse(start)
+ .start(startIndex).create();
+ TabItem tabItem;
+ float previousProjectedPosition = removedTabItem.getTag().getPosition();
+
+ while ((tabItem = iterator.next()) != null &&
+ (tabItem.getTag().getState() == State.HIDDEN ||
+ tabItem.getTag().getState() == State.STACKED_START ||
+ tabItem.getTag().getState() == State.STACKED_START_ATOP ||
+ tabItem.getTag().getState() == State.STACKED_END)) {
+ float projectedPosition = tabItem.getTag().getPosition();
+
+ if (tabItem.getTag().getState() == State.HIDDEN) {
+ TabItem previous = iterator.previous();
+ tabItem.getTag().setState(previous.getTag().getState());
+
+ if (tabItem.isVisible()) {
+ Pair pair = start ?
+ calculatePositionAndStateWhenStackedAtStart(getTabSwitcher().getCount(),
+ tabItem.getIndex(), tabItem) :
+ calculatePositionAndStateWhenStackedAtEnd(tabItem.getIndex());
+ tabItem.getTag().setPosition(pair.first);
+ tabItem.getTag().setState(pair.second);
+ inflateAndUpdateView(tabItem, null);
+ }
+
+ break;
+ } else {
+ tabItem.getTag().setPosition(previousProjectedPosition);
+ long startDelay =
+ (Math.abs(startIndex - tabItem.getIndex()) + 1) * relocateAnimationDelay;
+ animateRelocate(tabItem, previousProjectedPosition, null, startDelay,
+ createRelocateAnimationListener(tabItem));
+ }
+
+ previousProjectedPosition = projectedPosition;
+ }
+ }
+
+ /**
+ * Relocates all previous tabs, when floating tabs have been added to the tab switcher.
+ *
+ * @param addedTabItems
+ * An array, which contains the tab items, which correspond to the tabs, which have been
+ * added, as an array of the type {@link TabItem}. The array may not be null
+ * @param referenceTabItem
+ * The tab item, which corresponds to the tab, which is used as a reference, as an
+ * instance of the class {@link TabItem}. The tab item may not be null
+ * @param isReferencingPredecessor
+ * True, if the tab, which is used as a reference, is the predecessor of the added tab,
+ * false if it is the successor
+ * @param attachedPosition
+ * The current attached position in pixels as a {@link Float} value
+ * @param attachedPositionChanged
+ * True, if adding the tab caused the attached position to be changed, false otherwise
+ * @return An array, which contains the tab items, which correspond to the tabs, which have been
+ * added, as an array of the type {@link TabItem}. The array may not be null
+ */
+ @NonNull
+ private TabItem[] relocateWhenAddingFloatingTabs(@NonNull final TabItem[] addedTabItems,
+ @NonNull final TabItem referenceTabItem,
+ final boolean isReferencingPredecessor,
+ final float attachedPosition,
+ final boolean attachedPositionChanged) {
+ int count = getTabSwitcher().getCount();
+ TabItem firstAddedTabItem = addedTabItems[0];
+ TabItem lastAddedTabItem = addedTabItems[addedTabItems.length - 1];
+
+ float referencePosition = referenceTabItem.getTag().getPosition();
+
+ if (isReferencingPredecessor && attachedPositionChanged &&
+ lastAddedTabItem.getIndex() < count - 1) {
+ int neighboringIndex = lastAddedTabItem.getIndex() + 1;
+ referencePosition -= Math.abs(referencePosition -
+ TabItem.create(getTabSwitcher(), viewRecycler, neighboringIndex).getTag()
+ .getPosition()) / 2f;
+ }
+
+ float initialReferencePosition = referencePosition;
+ int selectedTabIndex = getModel().getSelectedTabIndex();
+ TabItem selectedTabItem = TabItem.create(getTabSwitcher(), viewRecycler, selectedTabIndex);
+ float defaultTabSpacing = calculateMaxTabSpacing(count, null);
+ float maxTabSpacing = calculateMaxTabSpacing(count, selectedTabItem);
+ float minTabSpacing = calculateMinTabSpacing(count);
+ TabItem currentReferenceTabItem = referenceTabItem;
+ int referenceIndex = referenceTabItem.getIndex();
+
+ AbstractTabItemIterator.AbstractBuilder builder =
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler);
+
+ for (TabItem addedTabItem : addedTabItems) {
+ int iterationReferenceIndex = referenceIndex;
+ float iterationReferencePosition = referencePosition;
+ TabItem iterationReferenceTabItem = currentReferenceTabItem;
+ AbstractTabItemIterator iterator =
+ builder.start(addedTabItem.getIndex()).reverse(true).create();
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ TabItem predecessor = iterator.peek();
+ Pair pair;
+ float currentTabSpacing = calculateMaxTabSpacing(count, iterationReferenceTabItem);
+
+ if (isReferencingPredecessor && tabItem.getIndex() == addedTabItem.getIndex()) {
+ State predecessorState =
+ predecessor != null ? predecessor.getTag().getState() : null;
+ pair = clipTabPosition(count, tabItem.getIndex(), iterationReferencePosition,
+ predecessorState == State.STACKED_START_ATOP ? State.FLOATING :
+ predecessorState);
+ currentReferenceTabItem = iterationReferenceTabItem = tabItem;
+ initialReferencePosition =
+ referencePosition = iterationReferencePosition = pair.first;
+ referenceIndex = iterationReferenceIndex = tabItem.getIndex();
+ } else if (iterationReferencePosition >= attachedPosition - currentTabSpacing) {
+ float position;
+
+ if (selectedTabIndex > tabItem.getIndex() &&
+ selectedTabIndex <= iterationReferenceIndex) {
+ position = iterationReferencePosition + maxTabSpacing +
+ ((iterationReferenceIndex - tabItem.getIndex() - 1) *
+ defaultTabSpacing);
+ } else {
+ position = iterationReferencePosition +
+ ((iterationReferenceIndex - tabItem.getIndex()) *
+ defaultTabSpacing);
+ }
+
+ pair = clipTabPosition(count, tabItem.getIndex(), position, predecessor);
+ } else {
+ TabItem successor = iterator.previous();
+ float successorPosition = successor.getTag().getPosition();
+ float position = (attachedPosition * (successorPosition + minTabSpacing)) /
+ (minTabSpacing + attachedPosition - currentTabSpacing);
+ pair = clipTabPosition(count, tabItem.getIndex(), position, predecessor);
+
+ if (pair.first >= attachedPosition - currentTabSpacing) {
+ iterationReferenceTabItem = tabItem;
+ iterationReferencePosition = pair.first;
+ iterationReferenceIndex = tabItem.getIndex();
+ }
+ }
+
+ if (tabItem.getIndex() >= firstAddedTabItem.getIndex() &&
+ tabItem.getIndex() <= lastAddedTabItem.getIndex()) {
+ if (!isReferencingPredecessor && attachedPositionChanged && count > 3) {
+ TabItem successor = iterator.previous();
+ float successorPosition = successor.getTag().getPosition();
+ float position = pair.first - Math.abs(pair.first - successorPosition) / 2f;
+ pair = clipTabPosition(count, tabItem.getIndex(), position, predecessor);
+ initialReferencePosition = pair.first;
+ }
+
+ Tag tag = addedTabItems[tabItem.getIndex() - firstAddedTabItem.getIndex()]
+ .getTag();
+ tag.setPosition(pair.first);
+ tag.setState(pair.second);
+ } else {
+ Tag tag = tabItem.getTag().clone();
+ tag.setPosition(pair.first);
+ tag.setState(pair.second);
+
+ if (!tabItem.isInflated()) {
+ Pair pair2 =
+ calculatePositionAndStateWhenStackedAtEnd(tabItem.getIndex());
+ tabItem.getTag().setPosition(pair2.first);
+ tabItem.getTag().setState(pair2.second);
+ }
+
+ relocate(tabItem, tag.getPosition(), tag, 0);
+ }
+
+ if (pair.second == State.HIDDEN || pair.second == State.STACKED_END) {
+ firstVisibleIndex++;
+ break;
+ }
+ }
+ }
+
+ if (attachedPositionChanged && count > 3) {
+ AbstractTabItemIterator iterator =
+ builder.start(lastAddedTabItem.getIndex() + 1).reverse(false).create();
+ TabItem tabItem;
+ float previousPosition = initialReferencePosition;
+ Tag previousTag = lastAddedTabItem.getTag();
+
+ while ((tabItem = iterator.next()) != null && tabItem.getIndex() < count - 1) {
+ float position = calculateNonLinearPosition(previousPosition,
+ calculateMaxTabSpacing(count, tabItem));
+ Pair pair = clipTabPosition(count, tabItem.getIndex(), position,
+ previousTag.getState());
+ Tag tag = tabItem.getTag().clone();
+ tag.setPosition(pair.first);
+ tag.setState(pair.second);
+
+ if (!tabItem.isInflated()) {
+ Pair pair2 =
+ calculatePositionAndStateWhenStackedAtStart(count, tabItem.getIndex(),
+ iterator.previous());
+ tabItem.getTag().setPosition(pair2.first);
+ tabItem.getTag().setState(pair2.second);
+ }
+
+ relocate(tabItem, tag.getPosition(), tag, 0);
+ previousPosition = pair.first;
+ previousTag = tag;
+
+ if (pair.second == State.HIDDEN || pair.second == State.STACKED_START) {
+ break;
+ }
+ }
+ }
+
+ return addedTabItems;
+ }
+
+ /**
+ * Relocates all neighboring tabs, when stacked tabs have been added to the tab switcher.
+ *
+ * @param start
+ * True, if the added tab was part of the stack, which is located at the start, false,
+ * if it was part of the stack, which is located at the end
+ * @param addedTabItems
+ * An array, which contains the tab items, which correspond to the tabs, which have been
+ * added, as an array of the type {@link TabItem}. The array may not be null
+ * @return An array, which contains the tab items, which correspond to the tabs, which have been
+ * added, as an array of the type {@link TabItem}. The array may not be null
+ */
+ @NonNull
+ private TabItem[] relocateWhenAddingStackedTabs(final boolean start,
+ @NonNull final TabItem[] addedTabItems) {
+ if (!start) {
+ firstVisibleIndex += addedTabItems.length;
+ }
+
+ int count = getTabSwitcher().getCount();
+ TabItem firstAddedTabItem = addedTabItems[0];
+ TabItem lastAddedTabItem = addedTabItems[addedTabItems.length - 1];
+ AbstractTabItemIterator iterator =
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler)
+ .start(start ? lastAddedTabItem.getIndex() : firstAddedTabItem.getIndex())
+ .reverse(start).create();
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null &&
+ (tabItem.getTag().getState() == State.STACKED_START ||
+ tabItem.getTag().getState() == State.STACKED_START_ATOP ||
+ tabItem.getTag().getState() == State.STACKED_END ||
+ tabItem.getTag().getState() == State.HIDDEN)) {
+ TabItem predecessor = start ? iterator.peek() : iterator.previous();
+ Pair pair = start ?
+ calculatePositionAndStateWhenStackedAtStart(count, tabItem.getIndex(),
+ predecessor) :
+ calculatePositionAndStateWhenStackedAtEnd(tabItem.getIndex());
+
+ if (start && predecessor != null && predecessor.getTag().getState() == State.FLOATING) {
+ float predecessorPosition = predecessor.getTag().getPosition();
+ float distance = predecessorPosition - pair.first;
+
+ if (distance > calculateMinTabSpacing(count)) {
+ float position = calculateNonLinearPosition(tabItem, predecessor);
+ pair = clipTabPosition(count, tabItem.getIndex(), position, predecessor);
+ }
+ }
+
+ if (tabItem.getIndex() >= firstAddedTabItem.getIndex() &&
+ tabItem.getIndex() <= lastAddedTabItem.getIndex()) {
+ Tag tag = addedTabItems[tabItem.getIndex() - firstAddedTabItem.getIndex()].getTag();
+ tag.setPosition(pair.first);
+ tag.setState(pair.second);
+ } else if (tabItem.isInflated()) {
+ Tag tag = tabItem.getTag().clone();
+ tag.setPosition(pair.first);
+ tag.setState(pair.second);
+ animateRelocate(tabItem, tag.getPosition(), tag, 0,
+ createRelocateAnimationListener(tabItem));
+ } else {
+ break;
+ }
+ }
+
+ return addedTabItems;
+ }
+
+ /**
+ * Calculates the position and state of hidden tabs, which have been added to the tab switcher.
+ *
+ * @param addedTabItems
+ * An array, which contains the tab items, which correspond to the tabs, which have been
+ * added, as an array of the type {@link TabItem}. The array may not be null
+ * @param referenceTabItem
+ * The tab item, which corresponds to the tab, which is used as a reference, as an
+ * instance of the class {@link TabItem}. The tab item may not be null
+ * @return An array, which contains the tab items, which correspond to the tabs, which have been
+ * added, as an array of the type {@link TabItem}. The array may not be null
+ */
+ @NonNull
+ private TabItem[] relocateWhenAddingHiddenTabs(@NonNull final TabItem[] addedTabItems,
+ @NonNull final TabItem referenceTabItem) {
+ boolean stackedAtStart = isStackedAtStart(referenceTabItem.getIndex());
+
+ for (TabItem tabItem : addedTabItems) {
+ Pair pair;
+
+ if (stackedAtStart) {
+ TabItem predecessor = tabItem.getIndex() > 0 ?
+ TabItem.create(getTabSwitcher(), viewRecycler, tabItem.getIndex() - 1) :
+ null;
+ pair = calculatePositionAndStateWhenStackedAtStart(getModel().getCount(),
+ tabItem.getIndex(), predecessor);
+ } else {
+ pair = calculatePositionAndStateWhenStackedAtEnd(tabItem.getIndex());
+ }
+
+ Tag tag = tabItem.getTag();
+ tag.setPosition(pair.first);
+ tag.setState(pair.second);
+ }
+
+ return addedTabItems;
+ }
+
+ /**
+ * Relocates a specific tab. If its view is now yet inflated, it is inflated first.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, which should be relocated, as an instance
+ * of the class {@link TabItem}. The tab item may not be null
+ * @param relocatePosition
+ * The position, the tab should be moved to, in pixels as an {@link Float} value
+ * @param tag
+ * The tag, which should be applied to the tab, once it has been relocated, as an
+ * instance of the class {@link Tag} or null, if no tag should be applied
+ * @param startDelay
+ * The start delay of the relocate animation in milliseconds as a {@link Long} value
+ */
+ private void relocate(@NonNull final TabItem tabItem, final float relocatePosition,
+ @Nullable final Tag tag, final long startDelay) {
+ if (tabItem.isInflated()) {
+ animateRelocate(tabItem, relocatePosition, tag, startDelay,
+ createRelocateAnimationListener(tabItem));
+ } else {
+ inflateAndUpdateView(tabItem,
+ createRelocateLayoutListener(tabItem, relocatePosition, tag, startDelay,
+ createRelocateAnimationListener(tabItem)));
+ tabItem.getView().setVisibility(View.INVISIBLE);
+ }
+ }
+
+ /**
+ * Swipes a specific tab.
+ *
+ * @param tabItem
+ * The tab item, which corresponds to the tab, which should be swiped, as an instance of
+ * the class {@link TabItem}. The tab item may not be null
+ * @param distance
+ * The distance, the tab should be swiped by, in pixels as a {@link Float} value
+ */
+ private void swipe(@NonNull final TabItem tabItem, final float distance) {
+ View view = tabItem.getView();
+
+ if (!tabItem.getTag().isClosing()) {
+ adaptStackOnSwipe(tabItem, tabItem.getIndex() + 1, getModel().getCount() - 1);
+ }
+
+ tabItem.getTag().setClosing(true);
+ float dragDistance = distance;
+
+ if (!tabItem.getTab().isCloseable()) {
+ dragDistance = (float) Math.pow(Math.abs(distance), 0.75);
+ dragDistance = distance < 0 ? dragDistance * -1 : dragDistance;
+ }
+
+ getArithmetics().setPivot(Axis.DRAGGING_AXIS, view,
+ getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.SWIPE));
+ getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view,
+ getArithmetics().getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.SWIPE));
+ float scale = getArithmetics().getScale(view, true);
+ float ratio = 1 - (Math.abs(dragDistance) / calculateSwipePosition());
+ float scaledClosedTabScale = swipedTabScale * scale;
+ float targetScale = scaledClosedTabScale + ratio * (scale - scaledClosedTabScale);
+ getArithmetics().setScale(Axis.DRAGGING_AXIS, view, targetScale);
+ getArithmetics().setScale(Axis.ORTHOGONAL_AXIS, view, targetScale);
+ view.setAlpha(swipedTabAlpha + ratio * (1 - swipedTabAlpha));
+ getArithmetics().setPosition(Axis.ORTHOGONAL_AXIS, view, dragDistance);
+ }
+
+ /**
+ * Moves the first tab to overlap the other tabs, when overshooting at the start.
+ *
+ * @param position
+ * The position of the first tab in pixels as a {@link Float} value
+ */
+ private void startOvershoot(final float position) {
+ TabItemIterator iterator =
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create();
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ if (tabItem.getIndex() == 0) {
+ View view = tabItem.getView();
+ getArithmetics().setPivot(Axis.DRAGGING_AXIS, view,
+ getArithmetics().getPivot(Axis.DRAGGING_AXIS, view, DragState.NONE));
+ getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view,
+ getArithmetics().getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.NONE));
+ getArithmetics().setPosition(Axis.DRAGGING_AXIS, view, position);
+ } else if (tabItem.isInflated()) {
+ View firstView = iterator.first().getView();
+ View view = tabItem.getView();
+ view.setVisibility(getArithmetics().getPosition(Axis.DRAGGING_AXIS, firstView) <=
+ getArithmetics().getPosition(Axis.DRAGGING_AXIS, view) ? View.INVISIBLE :
+ View.VISIBLE);
+ }
+ }
+ }
+
+ /**
+ * Tilts the tabs, when overshooting at the start.
+ *
+ * @param angle
+ * The angle, the tabs should be rotated by, in degrees as a {@link Float} value
+ */
+ private void tiltOnStartOvershoot(final float angle) {
+ TabItemIterator iterator =
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create();
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ View view = tabItem.getView();
+
+ if (tabItem.getIndex() == 0) {
+ view.setCameraDistance(maxCameraDistance);
+ getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, getArithmetics()
+ .getPivot(Axis.DRAGGING_AXIS, view, DragState.OVERSHOOT_START));
+ getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, getArithmetics()
+ .getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.OVERSHOOT_START));
+ getArithmetics().setRotation(Axis.ORTHOGONAL_AXIS, view, angle);
+ } else if (tabItem.isInflated()) {
+ tabItem.getView().setVisibility(View.INVISIBLE);
+ }
+ }
+ }
+
+ /**
+ * Tilts the tabs, when overshooting at the end.
+ *
+ * @param angle
+ * The angle, the tabs should be rotated by, in degrees as a {@link Float} value
+ */
+ private void tiltOnEndOvershoot(final float angle) {
+ float minCameraDistance = maxCameraDistance / 2f;
+ int firstVisibleIndex = -1;
+ TabItemIterator iterator =
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create();
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ if (tabItem.isInflated()) {
+ View view = tabItem.getView();
+
+ if (!iterator.hasNext()) {
+ view.setCameraDistance(maxCameraDistance);
+ } else if (firstVisibleIndex == -1) {
+ view.setCameraDistance(minCameraDistance);
+
+ if (tabItem.getTag().getState() == State.FLOATING) {
+ firstVisibleIndex = tabItem.getIndex();
+ }
+ } else {
+ int diff = tabItem.getIndex() - firstVisibleIndex;
+ float ratio =
+ (float) diff / (float) (getModel().getCount() - firstVisibleIndex);
+ view.setCameraDistance(
+ minCameraDistance + (maxCameraDistance - minCameraDistance) * ratio);
+ }
+
+ getArithmetics().setPivot(Axis.DRAGGING_AXIS, view, getArithmetics()
+ .getPivot(Axis.DRAGGING_AXIS, view, DragState.OVERSHOOT_END));
+ getArithmetics().setPivot(Axis.ORTHOGONAL_AXIS, view, getArithmetics()
+ .getPivot(Axis.ORTHOGONAL_AXIS, view, DragState.OVERSHOOT_END));
+ getArithmetics().setRotation(Axis.ORTHOGONAL_AXIS, view, angle);
+ }
+ }
+ }
+
+ /**
+ * Returns, whether a hidden tab at a specific index, is part of the stack, which is located at
+ * the start, or not.
+ *
+ * @param index
+ * The index of the hidden tab, as an {@link Integer} value
+ * @return True, if the hidden tab is part of the stack, which is located at the start, false
+ * otherwise
+ */
+ private boolean isStackedAtStart(final int index) {
+ boolean start = true;
+ AbstractTabItemIterator iterator =
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).start(index + 1)
+ .create();
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ State state = tabItem.getTag().getState();
+
+ if (state == State.STACKED_START) {
+ start = true;
+ break;
+ } else if (state == State.FLOATING) {
+ start = false;
+ break;
+ }
+ }
+
+ return start;
+ }
+
+ /**
+ * Creates a new layout, which implements the functionality of a {@link TabSwitcher} on
+ * smartphones.
+ *
+ * @param tabSwitcher
+ * The tab switcher, the layout belongs to, as an instance of the class {@link
+ * TabSwitcher}. The tab switcher may not be null
+ * @param model
+ * The model of the tab switcher, the layout belongs to, as an instance of the class
+ * {@link TabSwitcherModel}. The model may not be null
+ * @param arithmetics
+ * The arithmetics, which should be used by the layout, as an instance of the class
+ * {@link PhoneArithmetics}. The arithmetics may not be null
+ */
+ public PhoneTabSwitcherLayout(@NonNull final TabSwitcher tabSwitcher,
+ @NonNull final TabSwitcherModel model,
+ @NonNull final PhoneArithmetics arithmetics) {
+ super(tabSwitcher, model, arithmetics);
+ Resources resources = tabSwitcher.getResources();
+ tabInset = resources.getDimensionPixelSize(R.dimen.tab_inset);
+ tabBorderWidth = resources.getDimensionPixelSize(R.dimen.tab_border_width);
+ tabTitleContainerHeight =
+ resources.getDimensionPixelSize(R.dimen.tab_title_container_height);
+ stackedTabCount = resources.getInteger(R.integer.stacked_tab_count);
+ stackedTabSpacing = resources.getDimensionPixelSize(R.dimen.stacked_tab_spacing);
+ maxCameraDistance = resources.getDimensionPixelSize(R.dimen.max_camera_distance);
+ TypedValue typedValue = new TypedValue();
+ resources.getValue(R.dimen.swiped_tab_scale, typedValue, true);
+ swipedTabScale = typedValue.getFloat();
+ resources.getValue(R.dimen.swiped_tab_alpha, typedValue, true);
+ swipedTabAlpha = typedValue.getFloat();
+ showSwitcherAnimationDuration =
+ resources.getInteger(R.integer.show_switcher_animation_duration);
+ hideSwitcherAnimationDuration =
+ resources.getInteger(R.integer.hide_switcher_animation_duration);
+ toolbarVisibilityAnimationDuration =
+ resources.getInteger(R.integer.toolbar_visibility_animation_duration);
+ toolbarVisibilityAnimationDelay =
+ resources.getInteger(R.integer.toolbar_visibility_animation_delay);
+ swipeAnimationDuration = resources.getInteger(R.integer.swipe_animation_duration);
+ clearAnimationDelay = resources.getInteger(R.integer.clear_animation_delay);
+ relocateAnimationDuration = resources.getInteger(R.integer.relocate_animation_duration);
+ relocateAnimationDelay = resources.getInteger(R.integer.relocate_animation_delay);
+ revertOvershootAnimationDuration =
+ resources.getInteger(R.integer.revert_overshoot_animation_duration);
+ revealAnimationDuration = resources.getInteger(R.integer.reveal_animation_duration);
+ peekAnimationDuration = resources.getInteger(R.integer.peek_animation_duration);
+ maxStartOvershootAngle = resources.getInteger(R.integer.max_start_overshoot_angle);
+ maxEndOvershootAngle = resources.getInteger(R.integer.max_end_overshoot_angle);
+ tabViewBottomMargin = -1;
+ toolbarAnimation = null;
+ }
+
+ @NonNull
+ @Override
+ protected final AbstractDragHandler> onInflateLayout(final boolean tabsOnly) {
+ LayoutInflater inflater = LayoutInflater.from(getContext());
+
+ if (tabsOnly) {
+ toolbar = (Toolbar) getTabSwitcher().findViewById(R.id.primary_toolbar);
+ } else {
+ toolbar = (Toolbar) inflater.inflate(R.layout.phone_toolbar, getTabSwitcher(), false);
+ toolbar.setVisibility(getModel().areToolbarsShown() ? View.VISIBLE : View.INVISIBLE);
+ getTabSwitcher().addView(toolbar);
+ }
+
+ tabContainer = new FrameLayout(getContext());
+ getTabSwitcher().addView(tabContainer, FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT);
+ childViewRecycler = new ViewRecycler<>(inflater);
+ recyclerAdapter = new PhoneRecyclerAdapter(getTabSwitcher(), getModel(), childViewRecycler);
+ getModel().addListener(recyclerAdapter);
+ viewRecycler = new AttachedViewRecycler<>(tabContainer, inflater,
+ Collections.reverseOrder(new TabItem.Comparator(getTabSwitcher())));
+ viewRecycler.setAdapter(recyclerAdapter);
+ recyclerAdapter.setViewRecycler(viewRecycler);
+ dragHandler = new PhoneDragHandler(getTabSwitcher(), getArithmetics(), viewRecycler);
+ adaptLogLevel();
+ adaptDecorator();
+ adaptToolbarMargin();
+ return dragHandler;
+ }
+
+ @Nullable
+ @Override
+ protected final Pair onDetachLayout(final boolean tabsOnly) {
+ Pair result = null;
+
+ if (getModel().isSwitcherShown() && firstVisibleIndex != -1) {
+ TabItem tabItem = TabItem.create(getModel(), viewRecycler, firstVisibleIndex);
+ Tag tag = tabItem.getTag();
+
+ if (tag.getState() != State.HIDDEN) {
+ float firstVisibleTabPosition = tabItem.getTag().getPosition();
+ result = Pair.create(firstVisibleIndex, firstVisibleTabPosition);
+ }
+ }
+
+ childViewRecycler.removeAll();
+ childViewRecycler.clearCache();
+ viewRecycler.removeAll();
+ viewRecycler.clearCache();
+ recyclerAdapter.clearCachedPreviews();
+
+ if (!tabsOnly) {
+ getModel().removeListener(recyclerAdapter);
+ getTabSwitcher().removeView(toolbar);
+ getTabSwitcher().removeView(tabContainer);
+ }
+
+ return result;
+ }
+
+ @Override
+ public final boolean handleTouchEvent(@NonNull final MotionEvent event) {
+ return dragHandler.handleTouchEvent(event);
+ }
+
+ @Nullable
+ @Override
+ public final ViewGroup getTabContainer() {
+ return tabContainer;
+ }
+
+ @Nullable
+ @Override
+ public final Toolbar[] getToolbars() {
+ return new Toolbar[]{toolbar};
+ }
+
+ @Override
+ public final void onLogLevelChanged(@NonNull final LogLevel logLevel) {
+ adaptLogLevel();
+ }
+
+ @Override
+ public final void onDecoratorChanged(@NonNull final TabSwitcherDecorator decorator) {
+ adaptDecorator();
+ super.onDecoratorChanged(decorator);
+ }
+
+ @Override
+ public final void onSwitcherShown() {
+ getLogger().logInfo(getClass(), "Showed tab switcher");
+ animateShowSwitcher();
+ }
+
+ @Override
+ public final void onSwitcherHidden() {
+ getLogger().logInfo(getClass(), "Hid tab switcher");
+ animateHideSwitcher();
+ }
+
+ @Override
+ public final void onSelectionChanged(final int previousIndex, final int index,
+ @Nullable final Tab selectedTab,
+ final boolean switcherHidden) {
+ getLogger().logInfo(getClass(), "Selected tab at index " + index);
+
+ if (switcherHidden) {
+ animateHideSwitcher();
+ } else {
+ viewRecycler.remove(TabItem.create(getTabSwitcher(), viewRecycler, previousIndex));
+ viewRecycler.inflate(TabItem.create(getTabSwitcher(), viewRecycler, index));
+ }
+ }
+
+ @Override
+ public final void onTabAdded(final int index, @NonNull final Tab tab,
+ final int previousSelectedTabIndex, final int selectedTabIndex,
+ final boolean switcherVisibilityChanged,
+ @NonNull final Animation animation) {
+ getLogger().logInfo(getClass(),
+ "Added tab at index " + index + " using a " + animation.getClass().getSimpleName());
+
+ if (animation instanceof PeekAnimation && !getModel().isEmpty()) {
+ ensureTrue(switcherVisibilityChanged, animation.getClass().getSimpleName() +
+ " not supported when the tab switcher is shown");
+ PeekAnimation peekAnimation = (PeekAnimation) animation;
+ TabItem tabItem = new TabItem(0, tab);
+ inflateView(tabItem, createPeekLayoutListener(tabItem, peekAnimation));
+ } else if (animation instanceof RevealAnimation && switcherVisibilityChanged) {
+ TabItem tabItem = new TabItem(0, tab);
+ RevealAnimation revealAnimation = (RevealAnimation) animation;
+ inflateView(tabItem, createRevealLayoutListener(tabItem, revealAnimation));
+ } else {
+ addAllTabs(index, new Tab[]{tab}, animation);
+ }
+ }
+
+ @Override
+ public final void onAllTabsAdded(final int index, @NonNull final Tab[] tabs,
+ final int previousSelectedTabIndex, final int selectedTabIndex,
+ @NonNull final Animation animation) {
+ ensureTrue(animation instanceof SwipeAnimation,
+ animation.getClass().getSimpleName() + " not supported for adding multiple tabs");
+ getLogger().logInfo(getClass(),
+ "Added " + tabs.length + " tabs at index " + index + " using a " +
+ animation.getClass().getSimpleName());
+ addAllTabs(index, tabs, animation);
+ }
+
+ @Override
+ public final void onTabRemoved(final int index, @NonNull final Tab tab,
+ final int previousSelectedTabIndex, final int selectedTabIndex,
+ @NonNull final Animation animation) {
+ ensureTrue(animation instanceof SwipeAnimation,
+ animation.getClass().getSimpleName() + " not supported for removing tabs");
+ getLogger().logInfo(getClass(), "Removed tab at index " + index + " using a " +
+ animation.getClass().getSimpleName());
+ TabItem removedTabItem = TabItem.create(viewRecycler, index, tab);
+
+ if (!getModel().isSwitcherShown()) {
+ viewRecycler.remove(removedTabItem);
+
+ if (getModel().isEmpty()) {
+ toolbar.setAlpha(getModel().areToolbarsShown() ? 1 : 0);
+ } else if (selectedTabIndex != previousSelectedTabIndex) {
+ viewRecycler
+ .inflate(TabItem.create(getTabSwitcher(), viewRecycler, selectedTabIndex));
+ }
+ } else {
+ adaptStackOnSwipe(removedTabItem, removedTabItem.getIndex(), getModel().getCount());
+ removedTabItem.getTag().setClosing(true);
+ SwipeAnimation swipeAnimation =
+ animation instanceof SwipeAnimation ? (SwipeAnimation) animation :
+ new SwipeAnimation.Builder().create();
+
+ if (removedTabItem.isInflated()) {
+ animateRemove(removedTabItem, swipeAnimation);
+ } else {
+ boolean start = isStackedAtStart(index);
+ TabItem predecessor = TabItem.create(getTabSwitcher(), viewRecycler, index - 1);
+ Pair pair = start ?
+ calculatePositionAndStateWhenStackedAtStart(getModel().getCount(), index,
+ predecessor) : calculatePositionAndStateWhenStackedAtEnd(index);
+ removedTabItem.getTag().setPosition(pair.first);
+ removedTabItem.getTag().setState(pair.second);
+ inflateAndUpdateView(removedTabItem,
+ createRemoveLayoutListener(removedTabItem, swipeAnimation));
+ }
+ }
+ }
+
+ @Override
+ public final void onAllTabsRemoved(@NonNull final Tab[] tabs,
+ @NonNull final Animation animation) {
+ ensureTrue(animation instanceof SwipeAnimation,
+ animation.getClass().getSimpleName() + " not supported for removing tabs ");
+ getLogger().logInfo(getClass(),
+ "Removed all tabs using a " + animation.getClass().getSimpleName());
+
+ if (!getModel().isSwitcherShown()) {
+ viewRecycler.removeAll();
+ toolbar.setAlpha(getModel().areToolbarsShown() ? 1 : 0);
+ } else {
+ SwipeAnimation swipeAnimation =
+ animation instanceof SwipeAnimation ? (SwipeAnimation) animation :
+ new SwipeAnimation.Builder().create();
+ AbstractTabItemIterator iterator =
+ new ArrayTabItemIterator.Builder(viewRecycler, tabs).reverse(true).create();
+ TabItem tabItem;
+ int startDelay = 0;
+
+ while ((tabItem = iterator.next()) != null) {
+ TabItem previous = iterator.previous();
+
+ if (tabItem.getTag().getState() == State.FLOATING ||
+ (previous != null && previous.getTag().getState() == State.FLOATING)) {
+ startDelay += clearAnimationDelay;
+ }
+
+ if (tabItem.isInflated()) {
+ animateSwipe(tabItem, true, startDelay, swipeAnimation,
+ !iterator.hasNext() ? createClearAnimationListener() : null);
+ }
+ }
+ }
+ }
+
+ @Override
+ public final void onPaddingChanged(final int left, final int top, final int right,
+ final int bottom) {
+ adaptToolbarMargin();
+ }
+
+ @Override
+ public final void onTabIconChanged(@Nullable final Drawable icon) {
+
+ }
+
+ @Override
+ public final void onTabBackgroundColorChanged(@Nullable final ColorStateList colorStateList) {
+
+ }
+
+ @Override
+ public final void onTabTitleColorChanged(@Nullable final ColorStateList colorStateList) {
+
+ }
+
+ @Override
+ public final void onTabCloseButtonIconChanged(@Nullable final Drawable icon) {
+
+ }
+
+ @Override
+ public final void onGlobalLayout() {
+ if (getModel().isSwitcherShown()) {
+ TabItem[] tabItems = calculateInitialTabItems(getModel().getFirstVisibleTabIndex(),
+ getModel().getFirstVisibleTabPosition());
+ AbstractTabItemIterator iterator = new InitialTabItemIterator(tabItems, false, 0);
+ TabItem tabItem;
+
+ while ((tabItem = iterator.next()) != null) {
+ if (tabItem.isVisible()) {
+ inflateAndUpdateView(tabItem, createBottomMarginLayoutListener(tabItem));
+ }
+ }
+
+ toolbar.setAlpha(getModel().areToolbarsShown() ? 1 : 0);
+ } else if (getModel().getSelectedTab() != null) {
+ TabItem tabItem = TabItem.create(getTabSwitcher(), viewRecycler,
+ getModel().getSelectedTabIndex());
+ viewRecycler.inflate(tabItem);
+ }
+ }
+
+ @Nullable
+ @Override
+ public final DragState onDrag(@NonNull final DragState dragState, final float dragDistance) {
+ if (dragDistance != 0) {
+ if (dragState == DragState.DRAG_TO_END) {
+ calculatePositionsWhenDraggingToEnd(dragDistance);
+ } else {
+ calculatePositionsWhenDraggingToStart(dragDistance);
+ }
+ }
+
+ DragState overshoot = isOvershootingAtEnd(
+ new TabItemIterator.Builder(getTabSwitcher(), viewRecycler).create()) ?
+ DragState.OVERSHOOT_END :
+ (isOvershootingAtStart() ? DragState.OVERSHOOT_START : null);
+ getLogger().logVerbose(getClass(),
+ "Dragging using a distance of " + dragDistance + " pixels. Drag state is " +
+ dragState + ", overshoot is " + overshoot);
+ return overshoot;
+ }
+
+ @Override
+ public final void onClick(@NonNull final TabItem tabItem) {
+ getModel().selectTab(tabItem.getTab());
+ getLogger().logVerbose(getClass(), "Clicked tab at index " + tabItem.getIndex());
+ }
+
+ @Override
+ public final void onRevertStartOvershoot() {
+ animateRevertStartOvershoot();
+ getLogger().logVerbose(getClass(), "Reverting overshoot at the start");
+ }
+
+ @Override
+ public final void onRevertEndOvershoot() {
+ animateRevertEndOvershoot();
+ getLogger().logVerbose(getClass(), "Reverting overshoot at the end");
+ }
+
+ public final void onStartOvershoot(final float position) {
+ startOvershoot(position);
+ getLogger().logVerbose(getClass(),
+ "Overshooting at the start using a position of " + position + " pixels");
+ }
+
+ @Override
+ public final void onTiltOnStartOvershoot(final float angle) {
+ tiltOnStartOvershoot(angle);
+ getLogger().logVerbose(getClass(),
+ "Tilting on start overshoot using an angle of " + angle + " degrees");
+ }
+
+ @Override
+ public final void onTiltOnEndOvershoot(final float angle) {
+ tiltOnEndOvershoot(angle);
+ getLogger().logVerbose(getClass(),
+ "Tilting on end overshoot using an angle of " + angle + " degrees");
+ }
+
+ @Override
+ public final void onSwipe(@NonNull final TabItem tabItem, final float distance) {
+ swipe(tabItem, distance);
+ getLogger().logVerbose(getClass(),
+ "Swiping tab at index " + tabItem.getIndex() + ". Current swipe distance is " +
+ distance + " pixels");
+ }
+
+ @Override
+ public final void onSwipeEnded(@NonNull final TabItem tabItem, final boolean remove,
+ final float velocity) {
+ if (remove) {
+ View view = tabItem.getView();
+ SwipeDirection direction =
+ getArithmetics().getPosition(Axis.ORTHOGONAL_AXIS, view) < 0 ?
+ SwipeDirection.LEFT : SwipeDirection.RIGHT;
+ long animationDuration =
+ velocity > 0 ? Math.round((calculateSwipePosition() / velocity) * 1000) : -1;
+ Animation animation = new SwipeAnimation.Builder().setDirection(direction)
+ .setDuration(animationDuration).create();
+ getModel().removeTab(tabItem.getTab(), animation);
+ } else {
+ animateSwipe(tabItem, false, 0, new SwipeAnimation.Builder().create(),
+ createSwipeAnimationListener(tabItem));
+ }
+
+ getLogger().logVerbose(getClass(),
+ "Ended swiping tab at index " + tabItem.getIndex() + ". Tab will " +
+ (remove ? "" : "not ") + "be removed");
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneTabViewHolder.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneTabViewHolder.java
new file mode 100755
index 0000000..f655077
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PhoneTabViewHolder.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.layout.phone;
+
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+
+import de.mrapp.android.tabswitcher.TabSwitcher;
+import de.mrapp.android.tabswitcher.layout.AbstractTabViewHolder;
+
+/**
+ * A view holder, which allows to store references to the views, a tab of a {@link TabSwitcher}
+ * consists of, when using the smartphone layout.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class PhoneTabViewHolder extends AbstractTabViewHolder {
+
+ /**
+ * The view group, which contains the title and close button of a tab.
+ */
+ public ViewGroup titleContainer;
+
+ /**
+ * The view group, which contains the child view of a tab.
+ */
+ public ViewGroup childContainer;
+
+ /**
+ * The child view, which contains the tab's content.
+ */
+ public View child;
+
+ /**
+ * The image view, which is used to display the preview of a tab.
+ */
+ public ImageView previewImageView;
+
+ /**
+ * The view, which is used to display a border around the preview of a tab.
+ */
+ public View borderView;
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PreviewDataBinder.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PreviewDataBinder.java
new file mode 100755
index 0000000..cecd364
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/layout/phone/PreviewDataBinder.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.layout.phone;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v4.util.LruCache;
+import android.support.v4.util.Pair;
+import android.view.View;
+import android.view.View.MeasureSpec;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+
+import de.mrapp.android.tabswitcher.Tab;
+import de.mrapp.android.tabswitcher.model.TabItem;
+import de.mrapp.android.util.multithreading.AbstractDataBinder;
+import de.mrapp.android.util.view.ViewRecycler;
+
+import static de.mrapp.android.util.Condition.ensureNotNull;
+
+/**
+ * A data binder, which allows to asynchronously render preview images of tabs and display them
+ * afterwards.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class PreviewDataBinder extends AbstractDataBinder {
+
+ /**
+ * The parent view of the tab switcher, the tabs belong to.
+ */
+ private final ViewGroup parent;
+
+ /**
+ * The view recycler, which is used to inflate child views.
+ */
+ private final ViewRecycler childViewRecycler;
+
+ /**
+ * Creates a new data binder, which allows to asynchronously render preview images of tabs and
+ * display them afterwards.
+ *
+ * @param parent
+ * The parent view of the tab switcher, the tabs belong to, as an instance of the class
+ * {@link ViewGroup}. The parent may not be null
+ * @param childViewRecycler
+ * The view recycler, which should be used to inflate child views, as an instance of the
+ * class ViewRecycler. The view recycler may not be null
+ */
+ public PreviewDataBinder(@NonNull final ViewGroup parent,
+ @NonNull final ViewRecycler childViewRecycler) {
+ super(parent.getContext(), new LruCache(7));
+ ensureNotNull(parent, "The parent may not be null");
+ ensureNotNull(childViewRecycler, "The child view recycler may not be null");
+ this.parent = parent;
+ this.childViewRecycler = childViewRecycler;
+ }
+
+ @Override
+ protected final void onPreExecute(@NonNull final ImageView view,
+ @NonNull final TabItem... params) {
+ TabItem tabItem = params[0];
+ PhoneTabViewHolder viewHolder = tabItem.getViewHolder();
+ View child = viewHolder.child;
+ Tab tab = tabItem.getTab();
+
+ if (child == null) {
+ Pair pair = childViewRecycler.inflate(tab, viewHolder.childContainer);
+ child = pair.first;
+ } else {
+ childViewRecycler.getAdapter().onShowView(getContext(), child, tab, false);
+ }
+
+ viewHolder.child = child;
+ }
+
+ @Nullable
+ @Override
+ protected final Bitmap doInBackground(@NonNull final Tab key,
+ @NonNull final TabItem... params) {
+ TabItem tabItem = params[0];
+ PhoneTabViewHolder viewHolder = tabItem.getViewHolder();
+ View child = viewHolder.child;
+ viewHolder.child = null;
+ int width = parent.getWidth();
+ int height = parent.getHeight();
+ child.measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
+ child.layout(0, 0, child.getMeasuredWidth(), child.getMeasuredHeight());
+ Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+ child.draw(canvas);
+ return bitmap;
+ }
+
+ @Override
+ protected final void onPostExecute(@NonNull final ImageView view, @Nullable final Bitmap data,
+ @NonNull final TabItem... params) {
+ view.setImageBitmap(data);
+ view.setVisibility(data != null ? View.VISIBLE : View.GONE);
+ TabItem tabItem = params[0];
+ childViewRecycler.remove(tabItem.getTab());
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Model.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Model.java
new file mode 100755
index 0000000..0fa6250
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Model.java
@@ -0,0 +1,915 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.model;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
+import android.support.annotation.ColorInt;
+import android.support.annotation.DrawableRes;
+import android.support.annotation.MenuRes;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.StringRes;
+import android.support.v7.widget.Toolbar.OnMenuItemClickListener;
+import android.view.View.OnClickListener;
+
+import java.util.Collection;
+import java.util.NoSuchElementException;
+
+import de.mrapp.android.tabswitcher.Animation;
+import de.mrapp.android.tabswitcher.SwipeAnimation;
+import de.mrapp.android.tabswitcher.SwipeAnimation.SwipeDirection;
+import de.mrapp.android.tabswitcher.Tab;
+import de.mrapp.android.tabswitcher.TabCloseListener;
+import de.mrapp.android.tabswitcher.TabPreviewListener;
+import de.mrapp.android.tabswitcher.TabSwitcher;
+import de.mrapp.android.tabswitcher.TabSwitcherDecorator;
+import de.mrapp.android.util.logging.LogLevel;
+
+/**
+ * Defines the interface, a class, which implements the model of a {@link TabSwitcher} must
+ * implement.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public interface Model extends Iterable {
+
+ /**
+ * Defines the interface, a class, which should be notified about the model's events, must
+ * implement.
+ */
+ interface Listener {
+
+ /**
+ * The method, which is invoked, when the log level has been changed.
+ *
+ * @param logLevel
+ * The log level, which has been set, as a value of the enum LogLevel. The log level
+ * may not be null
+ */
+ void onLogLevelChanged(@NonNull LogLevel logLevel);
+
+ /**
+ * The method, which is invoked, when the decorator has been changed.
+ *
+ * @param decorator
+ * The decorator, which has been set, as an instance of the class {@link
+ * TabSwitcherDecorator}. The decorator may not be null
+ */
+ void onDecoratorChanged(@NonNull TabSwitcherDecorator decorator);
+
+ /**
+ * The method, which is invoked, when the tab switcher has been shown.
+ */
+ void onSwitcherShown();
+
+ /**
+ * The method, which is invoked, when the tab switcher has been hidden.
+ */
+ void onSwitcherHidden();
+
+ /**
+ * The method, which is invoked, when the currently selected tab has been changed.
+ *
+ * @param previousIndex
+ * The index of the previously selected tab as an {@link Integer} value or -1, if no
+ * tab was previously selected
+ * @param index
+ * The index of the currently selected tab as an {@link Integer} value or -1, if the
+ * tab switcher does not contain any tabs
+ * @param selectedTab
+ * The currently selected tab as an instance of the class {@link Tab} or null, if
+ * the tab switcher does not contain any tabs
+ * @param switcherHidden
+ * True, if selecting the tab caused the tab switcher to be hidden, false otherwise
+ */
+ void onSelectionChanged(int previousIndex, int index, @Nullable Tab selectedTab,
+ boolean switcherHidden);
+
+ /**
+ * The method, which is invoked, when a tab has been added to the model.
+ *
+ * @param index
+ * The index of the tab, which has been added, as an {@link Integer} value
+ * @param tab
+ * The tab, which has been added, as an instance of the class {@link Tab}. The tab
+ * may not be null
+ * @param previousSelectedTabIndex
+ * The index of the previously selected tab as an {@link Integer} value or -1, if no
+ * tab was selected
+ * @param selectedTabIndex
+ * The index of the currently selected tab as an {@link Integer} value or -1, if the
+ * tab switcher does not contain any tabs
+ * @param switcherVisibilityChanged
+ * True, if adding the tab caused the visibility of the tab switcher to be changed,
+ * false otherwise
+ * @param animation
+ * The animation, which has been used to add the tab, as an instance of the class
+ * {@link Animation}. The animation may not be null
+ */
+ void onTabAdded(int index, @NonNull Tab tab, int previousSelectedTabIndex,
+ int selectedTabIndex, boolean switcherVisibilityChanged,
+ @NonNull Animation animation);
+
+ /**
+ * The method, which is invoked, when multiple tabs have been added to the model.
+ *
+ * @param index
+ * The index of the first tab, which has been added, as an {@link Integer} value
+ * @param tabs
+ * An array, which contains the tabs, which have been added, as an array of the type
+ * {@link Tab} or an empty array, if no tabs have been added
+ * @param previousSelectedTabIndex
+ * The index of the previously selected tab as an {@link Integer} value or -1, if no
+ * tab was selected
+ * @param selectedTabIndex
+ * The index of the currently selected tab as an {@link Integer} value or -1, if the
+ * tab switcher does not contain any tabs
+ * @param animation
+ * The animation, which has been used to add the tabs, as an instance of the class
+ * {@link Animation}. The animation may not be null
+ */
+ void onAllTabsAdded(int index, @NonNull Tab[] tabs, int previousSelectedTabIndex,
+ int selectedTabIndex, @NonNull Animation animation);
+
+ /**
+ * The method, which is invoked, when a tab has been removed from the model.
+ *
+ * @param index
+ * The index of the tab, which has been removed, as an {@link Integer} value
+ * @param tab
+ * The tab, which has been removed, as an instance of the class {@link Tab}. The tab
+ * may not be null
+ * @param previousSelectedTabIndex
+ * The index of the previously selected tab as an {@link Integer} value or -1, if no
+ * tab was selected
+ * @param selectedTabIndex
+ * The index of the currently selected tab as an {@link Integer} value or -1, if the
+ * tab switcher does not contain any tabs
+ * @param animation
+ * The animation, which has been used to remove the tab, as an instance of the class
+ * {@link Animation}. The animation may not be null
+ */
+ void onTabRemoved(int index, @NonNull Tab tab, int previousSelectedTabIndex,
+ int selectedTabIndex, @NonNull Animation animation);
+
+ /**
+ * The method, which is invoked, when all tabs have been removed from the tab switcher.
+ *
+ * @param tabs
+ * An array, which contains the tabs, which have been removed, as an array of the
+ * type {@link Tab} or an empty array, if no tabs have been removed
+ * @param animation
+ * The animation, which has been used to remove the tabs, as an instance of the
+ * class {@link Animation}. The animation may not be null
+ */
+ void onAllTabsRemoved(@NonNull Tab[] tabs, @NonNull Animation animation);
+
+ /**
+ * The method, which is invoked, when the padding has been changed.
+ *
+ * @param left
+ * The left padding, which has been set, in pixels as an {@link Integer} value
+ * @param top
+ * The top padding, which has been set, in pixels as an {@link Integer} value
+ * @param right
+ * The right padding, which has been set, in pixels as an {@link Integer} value
+ * @param bottom
+ * The bottom padding, which has been set, in pixels as an {@link Integer} value
+ */
+ void onPaddingChanged(int left, int top, int right, int bottom);
+
+ /**
+ * The method, which is invoked, when the default icon of a tab has been changed.
+ *
+ * @param icon
+ * The icon, which has been set, as an instance of the class {@link Drawable} or
+ * null, if no icon is set
+ */
+ void onTabIconChanged(@Nullable Drawable icon);
+
+ /**
+ * The method, which is invoked, when the background color of a tab has been changed.
+ *
+ * @param colorStateList
+ * The color state list, which has been set, as an instance of the class {@link
+ * ColorStateList} or null, if the default color should be used
+ */
+ void onTabBackgroundColorChanged(@Nullable ColorStateList colorStateList);
+
+ /**
+ * The method, which is invoked, when the text color of a tab's title has been changed.
+ *
+ * @param colorStateList
+ * The color state list, which has been set, as an instance of the class {@link
+ * ColorStateList} or null, if the default color should be used
+ */
+ void onTabTitleColorChanged(@Nullable ColorStateList colorStateList);
+
+ /**
+ * The method, which is invoked, when the icon of a tab's close button has been changed.
+ *
+ * @param icon
+ * The icon, which has been set, as an instance of the class {@link Drawable} or
+ * null, if the default icon should be used
+ */
+ void onTabCloseButtonIconChanged(@Nullable Drawable icon);
+
+ /**
+ * The method, which is invoked, when it has been changed, whether the toolbars should be
+ * shown, when the tab switcher is shown, or not.
+ *
+ * @param visible
+ * True, if the toolbars should be shown, when the tab switcher is shown, false
+ * otherwise
+ */
+ void onToolbarVisibilityChanged(boolean visible);
+
+ /**
+ * The method, which is invoked, when the title of the toolbar, which is shown, when the tab
+ * switcher is shown, has been changed.
+ *
+ * @param title
+ * The title, which has been set, as an instance of the type {@link CharSequence} or
+ * null, if no title is set
+ */
+ void onToolbarTitleChanged(@Nullable CharSequence title);
+
+ /**
+ * The method, which is invoked, when the navigation icon of the toolbar, which is shown,
+ * when the tab switcher is shown, has been changed.
+ *
+ * @param icon
+ * The navigation icon, which has been set, as an instance of the class {@link
+ * Drawable} or null, if no navigation icon is set
+ * @param listener
+ * The listener, which should be notified, when the navigation item has been
+ * clicked, as an instance of the type {@link OnClickListener} or null, if no
+ * listener should be notified
+ */
+ void onToolbarNavigationIconChanged(@Nullable Drawable icon,
+ @Nullable OnClickListener listener);
+
+ /**
+ * The method, which is invoked, when the menu of the toolbar, which is shown, when the tab
+ * switcher is shown, has been inflated.
+ *
+ * @param resourceId
+ * The resource id of the menu, which has been inflated, as an {@link Integer}
+ * value. The resource id must correspond to a valid menu resource
+ * @param listener
+ * The listener, which has been registered to be notified, when an item of the menu
+ * has been clicked, as an instance of the type OnMenuItemClickListener or null, if
+ * no listener should be notified
+ */
+ void onToolbarMenuInflated(@MenuRes int resourceId,
+ @Nullable OnMenuItemClickListener listener);
+
+ }
+
+ /**
+ * Returns the context, which is used by the tab switcher.
+ *
+ * @return The context, which is used by the tab switcher, as an instance of the class {@link
+ * Context}. The context may not be null
+ */
+ @NonNull
+ Context getContext();
+
+ /**
+ * Sets the decorator, which allows to inflate the views, which correspond to the tabs of the
+ * tab switcher.
+ *
+ * @param decorator
+ * The decorator, which should be set, as an instance of the class {@link
+ * TabSwitcherDecorator}. The decorator may not be null
+ */
+ void setDecorator(@NonNull TabSwitcherDecorator decorator);
+
+ /**
+ * Returns the decorator, which allows to inflate the views, which correspond to the tabs of the
+ * tab switcher.
+ *
+ * @return The decorator as an instance of the class {@link TabSwitcherDecorator} or null, if no
+ * decorator has been set
+ */
+ TabSwitcherDecorator getDecorator();
+
+ /**
+ * Returns the log level, which is used for logging.
+ *
+ * @return The log level, which is used for logging, as a value of the enum LogLevel. The log
+ * level may not be null
+ */
+ @NonNull
+ LogLevel getLogLevel();
+
+ /**
+ * Sets the log level, which should be used for logging.
+ *
+ * @param logLevel
+ * The log level, which should be set, as a value of the enum LogLevel. The log level
+ * may not be null
+ */
+ void setLogLevel(@NonNull LogLevel logLevel);
+
+ /**
+ * Returns, whether the tab switcher is empty, or not.
+ *
+ * @return True, if the tab switcher is empty, false otherwise
+ */
+ boolean isEmpty();
+
+ /**
+ * Returns the number of tabs, which are contained by the tab switcher.
+ *
+ * @return The number of tabs, which are contained by the tab switcher, as an {@link Integer}
+ * value
+ */
+ int getCount();
+
+ /**
+ * Returns the tab at a specific index.
+ *
+ * @param index
+ * The index of the tab, which should be returned, as an {@link Integer} value. The
+ * index must be at least 0 and at maximum getCount() - 1
, otherwise a
+ * {@link IndexOutOfBoundsException} will be thrown
+ * @return The tab, which corresponds to the given index, as an instance of the class {@link
+ * Tab}. The tab may not be null
+ */
+ @NonNull
+ Tab getTab(int index);
+
+ /**
+ * Returns the index of a specific tab.
+ *
+ * @param tab
+ * The tab, whose index should be returned, as an instance of the class {@link Tab}. The
+ * tab may not be null
+ * @return The index of the given tab as an {@link Integer} value or -1, if the given tab is not
+ * contained by the tab switcher
+ */
+ int indexOf(@NonNull Tab tab);
+
+ /**
+ * Adds a new tab to the tab switcher. By default, the tab is added at the end. If the switcher
+ * is currently shown, the tab is added by using an animation. By default, a {@link
+ * SwipeAnimation} with direction {@link SwipeDirection#RIGHT} is used. If
+ * an animation is currently running, the tab will be added once all previously started
+ * animations have been finished.
+ *
+ * @param tab
+ * The tab, which should be added, as an instance of the class {@link Tab}. The tab may
+ * not be null
+ */
+ void addTab(@NonNull Tab tab);
+
+ /**
+ * Adds a new tab to the tab switcher at a specific index. If the switcher is currently shown,
+ * the tab is added by using an animation. By default, a {@link SwipeAnimation} with
+ * direction {@link SwipeDirection#RIGHT} is used. If an animation is currently
+ * running, the tab will be added once all previously started animations have been finished.
+ *
+ * @param tab
+ * The tab, which should be added, as an instance of the class {@link Tab}. The tab may
+ * not be null
+ * @param index
+ * The index, the tab should be added at, as an {@link Integer} value. The index must be
+ * at least 0 and at maximum getCount()
, otherwise an {@link
+ * IndexOutOfBoundsException} will be thrown
+ */
+ void addTab(@NonNull Tab tab, int index);
+
+ /**
+ * Adds a new tab to the tab switcher at a specific index. If the switcher is currently shown,
+ * the tab is added by using a specific animation. If an animation is currently
+ * running, the tab will be added once all previously started animations have been finished.
+ *
+ * @param tab
+ * The tab, which should be added, as an instance of the class {@link Tab}. The tab may
+ * not be null
+ * @param index
+ * The index, the tab should be added at, as an {@link Integer} value. The index must be
+ * at least 0 and at maximum getCount()
, otherwise an {@link
+ * IndexOutOfBoundsException} will be thrown
+ * @param animation
+ * The animation, which should be used to add the tab, as an instance of the class
+ * {@link Animation}. The animation may not be null
+ */
+ void addTab(@NonNull Tab tab, int index, @NonNull Animation animation);
+
+ /**
+ * Adds all tabs, which are contained by a collection, to the tab switcher. By default, the tabs
+ * are added at the end. If the switcher is currently shown, the tabs are added by using an
+ * animation. By default, a {@link SwipeAnimation} with direction {@link
+ * SwipeDirection#RIGHT} is used. If an animation is currently running, the tabs will
+ * be added once all previously started animations have been finished.
+ *
+ * @param tabs
+ * A collection, which contains the tabs, which should be added, as an instance of the
+ * type {@link Collection} or an empty collection, if no tabs should be added
+ */
+ void addAllTabs(@NonNull Collection extends Tab> tabs);
+
+ /**
+ * Adds all tabs, which are contained by a collection, to the tab switcher, starting at a
+ * specific index. If the switcher is currently shown, the tabs are added by using an animation.
+ * By default, a {@link SwipeAnimation} with direction {@link
+ * SwipeDirection#RIGHT} is used. If an animation is currently running, the tabs will
+ * be added once all previously started animations have been finished.
+ *
+ * @param tabs
+ * A collection, which contains the tabs, which should be added, as an instance of the
+ * type {@link Collection} or an empty collection, if no tabs should be added
+ * @param index
+ * The index, the first tab should be started at, as an {@link Integer} value. The index
+ * must be at least 0 and at maximum getCount()
, otherwise an {@link
+ * IndexOutOfBoundsException} will be thrown
+ */
+ void addAllTabs(@NonNull Collection extends Tab> tabs, int index);
+
+ /**
+ * Adds all tabs, which are contained by a collection, to the tab switcher, starting at a
+ * specific index. If the switcher is currently shown, the tabs are added by using a specific
+ * animation. If an animation is currently running, the tabs will be added once all previously
+ * started animations have been finished.
+ *
+ * @param tabs
+ * A collection, which contains the tabs, which should be added, as an instance of the
+ * type {@link Collection} or an empty collection, if no tabs should be added
+ * @param index
+ * The index, the first tab should be started at, as an {@link Integer} value. The index
+ * must be at least 0 and at maximum getCount()
, otherwise an {@link
+ * IndexOutOfBoundsException} will be thrown
+ * @param animation
+ * The animation, which should be used to add the tabs, as an instance of the class
+ * {@link Animation}. The animation may not be null
+ */
+ void addAllTabs(@NonNull Collection extends Tab> tabs, int index,
+ @NonNull Animation animation);
+
+ /**
+ * Adds all tabs, which are contained by an array, to the tab switcher. By default, the tabs are
+ * added at the end. If the switcher is currently shown, the tabs are added by using an
+ * animation. By default, a {@link SwipeAnimation} with direction {@link
+ * SwipeDirection#RIGHT} is used. If an animation is currently running, the tabs will
+ * be added once all previously started animations have been finished.
+ *
+ * @param tabs
+ * An array, which contains the tabs, which should be added, as an array of the type
+ * {@link Tab} or an empty array, if no tabs should be added
+ */
+ void addAllTabs(@NonNull Tab[] tabs);
+
+ /**
+ * Adds all tabs, which are contained by an array, to the tab switcher, starting at a specific
+ * index. If the switcher is currently shown, the tabs are added by using an animation. By
+ * default, a {@link SwipeAnimation} with direction {@link
+ * SwipeDirection#RIGHT} is used. If an animation is currently running, the tabs will
+ * be added once all previously started animations have been finished.
+ *
+ * @param tabs
+ * An array, which contains the tabs, which should be added, as an array of the type
+ * {@link Tab} or an empty array, if no tabs should be added
+ * @param index
+ * The index, the first tab should be started at, as an {@link Integer} value. The index
+ * must be at least 0 and at maximum getCount()
, otherwise an {@link
+ * IndexOutOfBoundsException} will be thrown
+ */
+ void addAllTabs(@NonNull Tab[] tabs, int index);
+
+ /**
+ * Adds all tabs, which are contained by an array, to the tab switcher, starting at a
+ * specific index. If the switcher is currently shown, the tabs are added by using a specific
+ * animation. If an animation is currently running, the tabs will be added once all previously
+ * started animations have been finished.
+ *
+ * @param tabs
+ * An array, which contains the tabs, which should be added, as an array of the type
+ * {@link Tab} or an empty array, if no tabs should be added
+ * @param index
+ * The index, the first tab should be started at, as an {@link Integer} value. The index
+ * must be at least 0 and at maximum getCount()
, otherwise an {@link
+ * IndexOutOfBoundsException} will be thrown
+ * @param animation
+ * The animation, which should be used to add the tabs, as an instance of the class
+ * {@link Animation}. The animation may not be null
+ */
+ void addAllTabs(@NonNull Tab[] tabs, int index, @NonNull Animation animation);
+
+ /**
+ * Removes a specific tab from the tab switcher. If the switcher is currently shown, the tab is
+ * removed by using an animation. By default, a {@link SwipeAnimation} with direction
+ * {@link SwipeDirection#RIGHT} is used. If an animation is currently running, the tab
+ * will be removed once all previously started animations have been finished.
+ *
+ * @param tab
+ * The tab, which should be removed, as an instance of the class {@link Tab}. The tab
+ * may not be null
+ */
+ void removeTab(@NonNull Tab tab);
+
+ /**
+ * Removes a specific tab from the tab switcher. If the switcher is currently shown, the tab is
+ * removed by using a specific animation. If an animation is currently running, the
+ * tab will be removed once all previously started animations have been finished.
+ *
+ * @param tab
+ * The tab, which should be removed, as an instance of the class {@link Tab}. The tab
+ * may not be null
+ * @param animation
+ * The animation, which should be used to remove the tabs, as an instance of the class
+ * {@link Animation}. The animation may not be null
+ */
+ void removeTab(@NonNull Tab tab, @NonNull Animation animation);
+
+ /**
+ * Removes all tabs from the tab switcher. If the switcher is currently shown, the tabs are
+ * removed by using an animation. By default, a {@link SwipeAnimation} with direction
+ * {@link SwipeDirection#RIGHT} is used. If an animation is currently running, the
+ * tabs will be removed once all previously started animations have been finished.
+ */
+ void clear();
+
+ /**
+ * Removes all tabs from the tab switcher. If the switcher is currently shown, the tabs are
+ * removed by using a specific animation. If an animation is currently running, the
+ * tabs will be removed once all previously started animations have been finished.
+ *
+ * @param animation
+ * The animation, which should be used to remove the tabs, as an instance of the class
+ * {@link Animation}. The animation may not be null
+ */
+ void clear(@NonNull Animation animation);
+
+ /**
+ * Returns, whether the tab switcher is currently shown.
+ *
+ * @return True, if the tab switcher is currently shown, false otherwise
+ */
+ boolean isSwitcherShown();
+
+ /**
+ * Shows the tab switcher by using an animation, if it is not already shown.
+ */
+ void showSwitcher();
+
+ /**
+ * Hides the tab switcher by using an animation, if it is currently shown.
+ */
+ void hideSwitcher();
+
+ /**
+ * Toggles the visibility of the tab switcher by using an animation, i.e. if the switcher is
+ * currently shown, it is hidden, otherwise it is shown.
+ */
+ void toggleSwitcherVisibility();
+
+ /**
+ * Returns the currently selected tab.
+ *
+ * @return The currently selected tab as an instance of the class {@link Tab} or null, if no tab
+ * is currently selected
+ */
+ @Nullable
+ Tab getSelectedTab();
+
+ /**
+ * Returns the index of the currently selected tab.
+ *
+ * @return The index of the currently selected tab as an {@link Integer} value or -1, if no tab
+ * is currently selected
+ */
+ int getSelectedTabIndex();
+
+ /**
+ * Selects a specific tab.
+ *
+ * @param tab
+ * The tab, which should be selected, as an instance of the class {@link Tab}. The tab
+ * may not be null. If the tab is not contained by the tab switcher, a {@link
+ * NoSuchElementException} will be thrown
+ */
+ void selectTab(@NonNull Tab tab);
+
+ /**
+ * Sets the padding of the tab switcher.
+ *
+ * @param left
+ * The left padding, which should be set, in pixels as an {@link Integer} value
+ * @param top
+ * The top padding, which should be set, in pixels as an {@link Integer} value
+ * @param right
+ * The right padding, which should be set, in pixels as an {@link Integer} value
+ * @param bottom
+ * The bottom padding, which should be set, in pixels as an {@link Integer} value
+ */
+ void setPadding(int left, int top, int right, int bottom);
+
+ /**
+ * Returns the left padding of the tab switcher.
+ *
+ * @return The left padding of the tab switcher in pixels as an {@link Integer} value
+ */
+ int getPaddingLeft();
+
+ /**
+ * Returns the top padding of the tab switcher.
+ *
+ * @return The top padding of the tab switcher in pixels as an {@link Integer} value
+ */
+ int getPaddingTop();
+
+ /**
+ * Returns the right padding of the tab switcher.
+ *
+ * @return The right padding of the tab switcher in pixels as an {@link Integer} value
+ */
+ int getPaddingRight();
+
+ /**
+ * Returns the bottom padding of the tab switcher.
+ *
+ * @return The bottom padding of the tab switcher in pixels as an {@link Integer} value
+ */
+ int getPaddingBottom();
+
+ /**
+ * Returns the start padding of the tab switcher. This corresponds to the right padding, if a
+ * right-to-left layout is used, or to the left padding otherwise.
+ *
+ * @return The start padding of the tab switcher in pixels as an {@link Integer} value
+ */
+ int getPaddingStart();
+
+ /**
+ * Returns the end padding of the tab switcher. This corresponds ot the left padding, if a
+ * right-to-left layout is used, or to the right padding otherwise.
+ *
+ * @return The end padding of the tab switcher in pixels as an {@link Integer} value
+ */
+ int getPaddingEnd();
+
+ /**
+ * Returns the default icon of a tab.
+ *
+ * @return The default icon of a tab as an instance of the class {@link Drawable} or null, if no
+ * icon is set
+ */
+ @Nullable
+ Drawable getTabIcon();
+
+ /**
+ * Sets the default icon of a tab.
+ *
+ * @param resourceId
+ * The resource id of the icon, which should be set, as an {@link Integer} value. The
+ * resource id must correspond to a valid drawable resource
+ */
+ void setTabIcon(@DrawableRes int resourceId);
+
+ /**
+ * Sets the default icon of a tab.
+ *
+ * @param icon
+ * The icon, which should be set, as an instance of the class {@link Bitmap} or null, if
+ * no icon should be set
+ */
+ void setTabIcon(@Nullable Bitmap icon);
+
+ /**
+ * Returns the default background color of a tab.
+ *
+ * @return The default background color of a tab as an instance of the class {@link
+ * ColorStateList} or null, if the default color is used
+ */
+ @Nullable
+ ColorStateList getTabBackgroundColor();
+
+ /**
+ * Sets the default background color of a tab.
+ *
+ * @param color
+ * The color, which should be set, as an {@link Integer} value or -1, if the default
+ * color should be used
+ */
+ void setTabBackgroundColor(@ColorInt int color);
+
+ /**
+ * Sets the default background color of a tab.
+ *
+ * @param colorStateList
+ * The color, which should be set, as an instance of the class {@link ColorStateList} or
+ * null, if the default color should be used
+ */
+ void setTabBackgroundColor(@Nullable ColorStateList colorStateList);
+
+ /**
+ * Returns the default text color of a tab's title.
+ *
+ * @return The default text color of a tab's title as an instance of the class {@link
+ * ColorStateList} or null, if the default color is used
+ */
+ @Nullable
+ ColorStateList getTabTitleTextColor();
+
+ /**
+ * Sets the default text color of a tab's title.
+ *
+ * @param color
+ * The color, which should be set, as an {@link Integer} value or -1, if the default
+ * color should be used
+ */
+ void setTabTitleTextColor(@ColorInt int color);
+
+ /**
+ * Sets the default text color of a tab's title.
+ *
+ * @param colorStateList
+ * The color state list, which should be set, as an instance of the class {@link
+ * ColorStateList} or null, if the default color should be used
+ */
+ void setTabTitleTextColor(@Nullable ColorStateList colorStateList);
+
+ /**
+ * Returns the default icon of a tab's close button.
+ *
+ * @return The default icon of a tab's close button as an instance of the class {@link Drawable}
+ * or null, if the default icon is used
+ */
+ @Nullable
+ Drawable getTabCloseButtonIcon();
+
+ /**
+ * Sets the default icon of a tab's close button.
+ *
+ * @param resourceId
+ * The resource id of the icon, which should be set, as an {@link Integer} value. The
+ * resource id must correspond to a valid drawable resource
+ */
+ void setTabCloseButtonIcon(@DrawableRes int resourceId);
+
+ /**
+ * Sets the default icon of a tab's close button.
+ *
+ * @param icon
+ * The icon, which should be set, as an instance of the class {@link Bitmap} or null, if
+ * the default icon should be used
+ */
+ void setTabCloseButtonIcon(@Nullable final Bitmap icon);
+
+ /**
+ * Returns, whether the toolbars are shown, when the tab switcher is shown, or not. When using
+ * the tablet layout, the toolbars are always shown.
+ *
+ * @return True, if the toolbars are shown, false otherwise
+ */
+ boolean areToolbarsShown();
+
+ /**
+ * Sets, whether the toolbars should be shown, when the tab switcher is shown, or not. This
+ * method does not have any effect when using the tablet layout.
+ *
+ * @param show
+ * True, if the toolbars should be shown, false otherwise
+ */
+ void showToolbars(boolean show);
+
+ /**
+ * Returns the title of the toolbar, which is shown, when the tab switcher is shown. When using
+ * the tablet layout, the title corresponds to the primary toolbar.
+ *
+ * @return The title of the toolbar, which is shown, when the tab switcher is shown, as an
+ * instance of the type {@link CharSequence} or null, if no title is set
+ */
+ @Nullable
+ CharSequence getToolbarTitle();
+
+ /**
+ * Sets the title of the toolbar, which is shown, when the tab switcher is shown. When using the
+ * tablet layout, the title is set to the primary toolbar.
+ *
+ * @param resourceId
+ * The resource id of the title, which should be set, as an {@link Integer} value. The
+ * resource id must correspond to a valid string resource
+ */
+ void setToolbarTitle(@StringRes int resourceId);
+
+ /**
+ * Sets the title of the toolbar, which is shown, when the tab switcher is shown. When using the
+ * tablet layout, the title is set to the primary toolbar.
+ *
+ * @param title
+ * The title, which should be set, as an instance of the type {@link CharSequence} or
+ * null, if no title should be set
+ */
+ void setToolbarTitle(@Nullable CharSequence title);
+
+ /**
+ * Returns the navigation icon of the toolbar, which is shown, when the tab switcher is shown.
+ * When using the tablet layout, the icon corresponds to the primary toolbar.
+ *
+ * @return The icon of the toolbar, which is shown, when the tab switcher is shown, as an
+ * instance of the class {@link Drawable} or null, if no icon is set
+ */
+ @Nullable
+ Drawable getToolbarNavigationIcon();
+
+ /**
+ * Sets the navigation icon of the toolbar, which is shown, when the tab switcher is shown. When
+ * using the tablet layout, the icon is set to the primary toolbar.
+ *
+ * @param resourceId
+ * The resource id of the icon, which should be set, as an {@link Integer} value. The
+ * resource id must correspond to a valid drawable resource
+ * @param listener
+ * The listener, which should be notified, when the navigation item has been clicked, as
+ * an instance of the type {@link OnClickListener} or null, if no listener should be
+ * notified
+ */
+ void setToolbarNavigationIcon(@DrawableRes int resourceId, @Nullable OnClickListener listener);
+
+ /**
+ * Sets the navigation icon of the toolbar, which is shown, when the tab switcher is shown. When
+ * using the tablet layout, the icon is set to the primary toolbar.
+ *
+ * @param icon
+ * The icon, which should be set, as an instance of the class {@link Drawable} or null,
+ * if no icon should be set
+ * @param listener
+ * The listener, which should be notified, when the navigation item has been clicked, as
+ * an instance of the type {@link OnClickListener} or null, if no listener should be
+ * notified
+ */
+ void setToolbarNavigationIcon(@Nullable Drawable icon, @Nullable OnClickListener listener);
+
+ /**
+ * Inflates the menu of the toolbar, which is shown, when the tab switcher is shown. When using
+ * the tablet layout, the menu is inflated into the secondary toolbar.
+ *
+ * @param resourceId
+ * The resource id of the menu, which should be inflated, as an {@link Integer} value.
+ * The resource id must correspond to a valid menu resource
+ * @param listener
+ * The listener, which should be notified, when an menu item has been clicked, as an
+ * instance of the type OnMenuItemClickListener or null, if no listener should be
+ * notified
+ */
+ void inflateToolbarMenu(@MenuRes int resourceId, @Nullable OnMenuItemClickListener listener);
+
+ /**
+ * Adds a new listener, which should be notified, when a tab is about to be closed by clicking
+ * its close button.
+ *
+ * @param listener
+ * The listener, which should be added, as an instance of the type {@link
+ * TabCloseListener}. The listener may not be null
+ */
+ void addCloseTabListener(@NonNull TabCloseListener listener);
+
+ /**
+ * Removes a specific listener, which should not be notified, when a tab is about to be closed
+ * by clicking its close button, anymore.
+ *
+ * @param listener
+ * The listener, which should be removed, as an instance of the type {@link
+ * TabCloseListener}. The listener may not be null
+ */
+ void removeCloseTabListener(@NonNull TabCloseListener listener);
+
+ /**
+ * Adds a new listener, which should be notified, when the preview of a tab is about to be
+ * loaded. Previews are only loaded when using the smartphone layout.
+ *
+ * @param listener
+ * The listener, which should be added, as an instance of the type {@link
+ * TabPreviewListener}. The listener may not be null
+ */
+ void addTabPreviewListener(@NonNull TabPreviewListener listener);
+
+ /**
+ * Removes a specific listener, which should not be notified, when the preview of a tab is about
+ * to be loaded.
+ *
+ * @param listener
+ * The listener, which should be removed, as an instance of the type {@link
+ * TabPreviewListener}. The listener may not be null
+ */
+ void removeTabPreviewListener(@NonNull TabPreviewListener listener);
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Restorable.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Restorable.java
new file mode 100755
index 0000000..39f8f69
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Restorable.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.model;
+
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+/**
+ * Defines the interface, a class, whose state should be stored and restored, must implement.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public interface Restorable {
+
+ /**
+ * Saves the current state.
+ *
+ * @param outState
+ * The bundle, which should be used to store the saved state, as an instance of the
+ * class {@link Bundle}. The bundle may not be null
+ */
+ void saveInstanceState(@NonNull Bundle outState);
+
+ /**
+ * Restores a previously saved state.
+ *
+ * @param savedInstanceState
+ * The saved state as an instance of the class {@link Bundle} or null, if no saved state
+ * is available
+ */
+ void restoreInstanceState(@Nullable Bundle savedInstanceState);
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/State.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/State.java
new file mode 100755
index 0000000..c652ee6
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/State.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.model;
+
+/**
+ * Contains all possible states of a tab, while the switcher is shown.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public enum State {
+
+ /**
+ * When the tab is part of the stack, which is located at the start of the switcher.
+ */
+ STACKED_START,
+
+ /**
+ * When the tab is displayed atop of the stack, which is located at the start of the switcher.
+ */
+ STACKED_START_ATOP,
+
+ /**
+ * When the tab is floating and freely movable.
+ */
+ FLOATING,
+
+ /**
+ * When the tab is part of the stack, which is located at the end of the switcher.
+ */
+ STACKED_END,
+
+ /**
+ * When the tab is currently not visible, i.e. if no view is inflated to visualize it.
+ */
+ HIDDEN
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/TabItem.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/TabItem.java
new file mode 100755
index 0000000..c676e38
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/TabItem.java
@@ -0,0 +1,305 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.model;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.view.View;
+
+import de.mrapp.android.tabswitcher.R;
+import de.mrapp.android.tabswitcher.Tab;
+import de.mrapp.android.tabswitcher.TabSwitcher;
+import de.mrapp.android.tabswitcher.layout.phone.PhoneTabViewHolder;
+import de.mrapp.android.util.view.AttachedViewRecycler;
+
+import static de.mrapp.android.util.Condition.ensureAtLeast;
+import static de.mrapp.android.util.Condition.ensureNotNull;
+
+/**
+ * An item, which contains information about a tab of a {@link TabSwitcher}.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class TabItem {
+
+ /**
+ * A comparator, which allows to compare two instances of the class {@link TabItem}.
+ */
+ public static class Comparator implements java.util.Comparator {
+
+ /**
+ * The tab switcher, the tab items, which are compared by the comparator, belong to.
+ */
+ private final TabSwitcher tabSwitcher;
+
+ /**
+ * Creates a new comparator, which allows to compare two instances of the class {@link
+ * TabItem}.
+ *
+ * @param tabSwitcher
+ * The tab switcher, the tab items, which should be compared by the comparator,
+ * belong to, as a instance of the class {@link TabSwitcher}. The tab switcher may
+ * not be null
+ */
+ public Comparator(@NonNull final TabSwitcher tabSwitcher) {
+ ensureNotNull(tabSwitcher, "The tab switcher may not be null");
+ this.tabSwitcher = tabSwitcher;
+ }
+
+ @Override
+ public int compare(final TabItem o1, final TabItem o2) {
+ Tab tab1 = o1.getTab();
+ Tab tab2 = o2.getTab();
+ int index1 = tabSwitcher.indexOf(tab1);
+ int index2 = tabSwitcher.indexOf(tab2);
+
+ if (index2 == -1) {
+ index2 = o2.getIndex();
+ }
+
+ if (index1 == -1 || index2 == -1) {
+ throw new RuntimeException("Tab not contained by tab switcher");
+ }
+
+ return index1 < index2 ? -1 : 1;
+ }
+
+ }
+
+ /**
+ * The index of the tab.
+ */
+ private final int index;
+
+ /**
+ * The tab.
+ */
+ private final Tab tab;
+
+ /**
+ * The view, which is used to visualize the tab.
+ */
+ private View view;
+
+ /**
+ * The view holder, which stores references the views, which belong to the tab.
+ */
+ private PhoneTabViewHolder viewHolder;
+
+ /**
+ * The tag, which is associated with the tab.
+ */
+ private Tag tag;
+
+ /**
+ * Creates a new item, which contains information about a tab of a {@link TabSwitcher}. By
+ * default, the item is neither associated with a view, nor with a view holder.
+ *
+ * @param index
+ * The index of the tab as an {@link Integer} value. The index must be at least 0
+ * @param tab
+ * The tab as an instance of the class {@link Tab}. The tab may not be null
+ */
+ public TabItem(final int index, @NonNull final Tab tab) {
+ ensureAtLeast(index, 0, "The index must be at least 0");
+ ensureNotNull(tab, "The tab may not be null");
+ this.index = index;
+ this.tab = tab;
+ this.view = null;
+ this.viewHolder = null;
+ this.tag = new Tag();
+ }
+
+ /**
+ * Creates a new item, which contains information about a tab of a tab switcher. By
+ * default, the item is neither associated with a view, nor with a view holder.
+ *
+ * @param model
+ * The model, the tab belongs to, as an instance of the type {@link Model}. The model
+ * may not be null
+ * @param viewRecycler
+ * The view recycler, which is used to reuse the views, which are used to visualize
+ * tabs, as an instance of the class AttachedViewRecycler. The view recycler may not be
+ * null
+ * @param index
+ * The index of the tab as an {@link Integer} value. The index must be at least 0
+ * @return The item, which has been created, as an instance of the class {@link TabItem}. The
+ * item may not be null
+ */
+ @NonNull
+ public static TabItem create(@NonNull final Model model,
+ @NonNull final AttachedViewRecycler viewRecycler,
+ final int index) {
+ Tab tab = model.getTab(index);
+ return create(viewRecycler, index, tab);
+ }
+
+ /**
+ * Creates a new item, which contains information about a specific tab. By default, the item is
+ * neither associated with a view, nor with a view holder.
+ *
+ * @param viewRecycler
+ * The view recycler, which is used to reuse the views, which are used to visualize
+ * tabs, as an instance of the class AttachedViewRecycler. The view recycler may not be
+ * null
+ * @param index
+ * The index of the tab as an {@link Integer} value. The index must be at least 0
+ * @param tab
+ * The tab as an instance of the class {@link Tab}. The tab may not be null
+ * @return The item, which has been created, as an instance of the class {@link TabItem}. The
+ * item may not be null
+ */
+ @NonNull
+ public static TabItem create(@NonNull final AttachedViewRecycler viewRecycler,
+ final int index, @NonNull final Tab tab) {
+ TabItem tabItem = new TabItem(index, tab);
+ View view = viewRecycler.getView(tabItem);
+
+ if (view != null) {
+ tabItem.setView(view);
+ tabItem.setViewHolder((PhoneTabViewHolder) view.getTag(R.id.tag_view_holder));
+ Tag tag = (Tag) view.getTag(R.id.tag_properties);
+
+ if (tag != null) {
+ tabItem.setTag(tag);
+ }
+ }
+
+ return tabItem;
+ }
+
+ /**
+ * Returns the index of the tab.
+ *
+ * @return The index of the tab as an {@link Integer} value. The index must be at least 0
+ */
+ public final int getIndex() {
+ return index;
+ }
+
+ /**
+ * Returns the tab.
+ *
+ * @return The tab as an instance of the class {@link Tab}. The tab may not be null
+ */
+ @NonNull
+ public final Tab getTab() {
+ return tab;
+ }
+
+ /**
+ * Returns the view, which is used to visualize the tab.
+ *
+ * @return The view, which is used to visualize the tab, as an instance of the class {@link
+ * View} or null, if no such view is currently inflated
+ */
+ public final View getView() {
+ return view;
+ }
+
+ /**
+ * Sets the view, which is used to visualize the tab.
+ *
+ * @param view
+ * The view, which should be set, as an instance of the class {@link View} or null, if
+ * no view should be set
+ */
+ public final void setView(@Nullable final View view) {
+ this.view = view;
+ }
+
+ /**
+ * Returns the view holder, which stores references to the views, which belong to the tab.
+ *
+ * @return The view holder as an instance of the class {@link PhoneTabViewHolder} or null, if no
+ * view is is currently inflated to visualize the tab
+ */
+ public final PhoneTabViewHolder getViewHolder() {
+ return viewHolder;
+ }
+
+ /**
+ * Sets the view holder, which stores references to the views, which belong to the tab.
+ *
+ * @param viewHolder
+ * The view holder, which should be set, as an instance of the class {@link
+ * PhoneTabViewHolder} or null, if no view holder should be set
+ */
+ public final void setViewHolder(@Nullable final PhoneTabViewHolder viewHolder) {
+ this.viewHolder = viewHolder;
+ }
+
+ /**
+ * Returns the tag, which is associated with the tab.
+ *
+ * @return The tag as an instance of the class {@link Tag}. The tag may not be null
+ */
+ @NonNull
+ public final Tag getTag() {
+ return tag;
+ }
+
+ /**
+ * Sets the tag, which is associated with the tab.
+ *
+ * @param tag
+ * The tag, which should be set, as an instance of the class {@link Tag}. The tag may
+ * not be null
+ */
+ public final void setTag(@NonNull final Tag tag) {
+ ensureNotNull(tag, "The tag may not be null");
+ this.tag = tag;
+ }
+
+ /**
+ * Returns, whether a view, which is used to visualize the tab, is currently inflated, or not.
+ *
+ * @return True, if a view, which is used to visualize the tab, is currently inflated, false
+ * otherwise
+ */
+ public final boolean isInflated() {
+ return view != null && viewHolder != null;
+ }
+
+ /**
+ * Returns, whether the tab is currently visible, or not.
+ *
+ * @return True, if the tab is currently visible, false otherwise
+ */
+ public final boolean isVisible() {
+ return tag.getState() != State.HIDDEN || tag.isClosing();
+ }
+
+ @Override
+ public final String toString() {
+ return "TabItem [index = " + index + "]";
+ }
+
+ @Override
+ public final int hashCode() {
+ return tab.hashCode();
+ }
+
+ @Override
+ public final boolean equals(final Object obj) {
+ if (obj == null)
+ return false;
+ if (obj.getClass() != getClass())
+ return false;
+ TabItem other = (TabItem) obj;
+ return tab.equals(other.tab);
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/TabSwitcherModel.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/TabSwitcherModel.java
new file mode 100755
index 0000000..d8bf9c2
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/TabSwitcherModel.java
@@ -0,0 +1,1298 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.model;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.Bundle;
+import android.support.annotation.ColorInt;
+import android.support.annotation.DrawableRes;
+import android.support.annotation.MenuRes;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.StringRes;
+import android.support.v4.content.ContextCompat;
+import android.support.v7.widget.Toolbar.OnMenuItemClickListener;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.NoSuchElementException;
+import java.util.Set;
+
+import de.mrapp.android.tabswitcher.Animation;
+import de.mrapp.android.tabswitcher.PeekAnimation;
+import de.mrapp.android.tabswitcher.RevealAnimation;
+import de.mrapp.android.tabswitcher.SwipeAnimation;
+import de.mrapp.android.tabswitcher.Tab;
+import de.mrapp.android.tabswitcher.TabCloseListener;
+import de.mrapp.android.tabswitcher.TabPreviewListener;
+import de.mrapp.android.tabswitcher.TabSwitcher;
+import de.mrapp.android.tabswitcher.TabSwitcherDecorator;
+import de.mrapp.android.tabswitcher.layout.ChildRecyclerAdapter;
+import de.mrapp.android.util.logging.LogLevel;
+
+import static de.mrapp.android.util.Condition.ensureNotEqual;
+import static de.mrapp.android.util.Condition.ensureNotNull;
+
+/**
+ * The model of a {@link TabSwitcher}.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class TabSwitcherModel implements Model, Restorable {
+
+ /**
+ * The name of the extra, which is used to store the index of the first visible tab within a
+ * bundle.
+ */
+ public static final String FIRST_VISIBLE_TAB_INDEX_EXTRA =
+ TabSwitcherModel.class.getName() + "::FirstVisibleIndex";
+
+ /**
+ * The name of the extra, which is used to store the position of the first visible tab within a
+ * bundle.
+ */
+ public static final String FIRST_VISIBLE_TAB_POSITION_EXTRA =
+ TabSwitcherModel.class.getName() + "::FirstVisiblePosition";
+
+ /**
+ * The name of the extra, which is used to store the log level within a bundle.
+ */
+ private static final String LOG_LEVEL_EXTRA = TabSwitcherModel.class.getName() + "::LogLevel";
+
+ /**
+ * The name of the extra, which is used to store the tabs within a bundle.
+ */
+ private static final String TABS_EXTRA = TabSwitcherModel.class.getName() + "::Tabs";
+
+ /**
+ * The name of the extra, which is used to store, whether the tab switcher is shown, or not,
+ * within a bundle.
+ */
+ private static final String SWITCHER_SHOWN_EXTRA =
+ TabSwitcherModel.class.getName() + "::SwitcherShown";
+
+ /**
+ * The name of the extra, which is used to store the selected tab within a bundle.
+ */
+ private static final String SELECTED_TAB_EXTRA =
+ TabSwitcherModel.class.getName() + "::SelectedTab";
+
+ /**
+ * The name of the extra, which is used to store the padding within a bundle.
+ */
+ private static final String PADDING_EXTRA = TabSwitcherModel.class.getName() + "::Padding";
+
+ /**
+ * The name of the extra, which is used to store the resource id of a tab's icon within a
+ * bundle.
+ */
+ private static final String TAB_ICON_ID_EXTRA =
+ TabSwitcherModel.class.getName() + "::TabIconId";
+
+ /**
+ * The name of the extra, which is used to store the bitmap of a tab's icon within a bundle.
+ */
+ private static final String TAB_ICON_BITMAP_EXTRA =
+ TabSwitcherModel.class.getName() + "::TabIconBitmap";
+
+ /**
+ * The name of the extra, which is used to store the background color of a tab within a bundle.
+ */
+ private static final String TAB_BACKGROUND_COLOR_EXTRA =
+ TabSwitcherModel.class.getName() + "::TabBackgroundColor";
+
+ /**
+ * The name of the extra, which is used to store the text color of a tab's title within a
+ * bundle.
+ */
+ private static final String TAB_TITLE_TEXT_COLOR_EXTRA =
+ TabSwitcherModel.class.getName() + "::TabTitleTextColor";
+
+ /**
+ * The name of the extra, which is used to store the resource id of a tab's icon within a
+ * bundle.
+ */
+ private static final String TAB_CLOSE_BUTTON_ICON_ID_EXTRA =
+ TabSwitcherModel.class.getName() + "::TabCloseButtonIconId";
+
+ /**
+ * The name of the extra, which is used to store the bitmap of a tab's icon within a bundle.
+ */
+ private static final String TAB_CLOSE_BUTTON_ICON_BITMAP_EXTRA =
+ TabSwitcher.class.getName() + "::TabCloseButtonIconBitmap";
+
+ /**
+ * The name of the extra, which is used to store, whether the toolbars are shown, or not, within
+ * a bundle.
+ */
+ private static final String SHOW_TOOLBARS_EXTRA =
+ TabSwitcher.class.getName() + "::ShowToolbars";
+
+ /**
+ * The name of the extra, which is used to store the title of the toolbar within a bundle.
+ */
+ private static final String TOOLBAR_TITLE_EXTRA =
+ TabSwitcher.class.getName() + "::ToolbarTitle";
+
+ /**
+ * The tab switcher, the model belongs to.
+ */
+ private final TabSwitcher tabSwitcher;
+
+ /**
+ * A set, which contains the listeners, which are notified about the model's events.
+ */
+ private final Set listeners;
+
+ /**
+ * The index of the first visible tab.
+ */
+ private int firstVisibleTabIndex;
+
+ /**
+ * The position of the first visible tab.
+ */
+ private float firstVisibleTabPosition;
+
+ /**
+ * The log level, which is used for logging.
+ */
+ private LogLevel logLevel;
+
+ /**
+ * A list, which contains the tabs, which are contained by the tab switcher.
+ */
+ private ArrayList tabs;
+
+ /**
+ * True, if the tab switcher is currently shown, false otherwise.
+ */
+ private boolean switcherShown;
+
+ /**
+ * The currently selected tab.
+ */
+ private Tab selectedTab;
+
+ /**
+ * The decorator, which allows to inflate the views, which correspond to the tab switcher's
+ * tabs.
+ */
+ private TabSwitcherDecorator decorator;
+
+ /**
+ * The adapter, which allows to inflate the child views of tabs.
+ */
+ private ChildRecyclerAdapter childRecyclerAdapter;
+
+ /**
+ * An array, which contains the left, top, right and bottom padding of the tab switcher.
+ */
+ private int[] padding;
+
+ /**
+ * The resource id of a tab's icon.
+ */
+ private int tabIconId;
+
+ /**
+ * The bitmap of a tab's icon.
+ */
+ private Bitmap tabIconBitmap;
+
+ /**
+ * The background color of a tab;
+ */
+ private ColorStateList tabBackgroundColor;
+
+ /**
+ * The text color of a tab's title.
+ */
+ private ColorStateList tabTitleTextColor;
+
+ /**
+ * The resource id of the icon of a tab's close button.
+ */
+ private int tabCloseButtonIconId;
+
+ /**
+ * The bitmap of the icon of a tab's close button.
+ */
+ private Bitmap tabCloseButtonIconBitmap;
+
+ /**
+ * True, if the toolbars should be shown, when the tab switcher is shown, false otherwise.
+ */
+ private boolean showToolbars;
+
+ /**
+ * The title of the toolbar, which is shown, when the tab switcher is shown.
+ */
+ private CharSequence toolbarTitle;
+
+ /**
+ * The navigation icon of the toolbar, which is shown, when the tab switcher is shown.
+ */
+ private Drawable toolbarNavigationIcon;
+
+ /**
+ * The listener, which is notified, when the navigation icon of the toolbar, which is shown,
+ * when the tab switcher is shown, has been clicked.
+ */
+ private OnClickListener toolbarNavigationIconListener;
+
+ /**
+ * The resource id of the menu of the toolbar, which is shown, when the tab switcher is shown.
+ */
+ private int toolbarMenuId;
+
+ /**
+ * The listener, which is notified, when an item of the menu of the toolbar, which is shown,
+ * when the tab switcher is shown, is clicked.
+ */
+ private OnMenuItemClickListener toolbarMenuItemListener;
+
+ /**
+ * A set, which contains the listeners, which should be notified, when a tab is about to be
+ * closed by clicking its close button.
+ */
+ private final Set tabCloseListeners;
+
+ /**
+ * A set, which contains the listeners, which should be notified, when the previews of tabs are
+ * about to be loaded.
+ */
+ private final Set tabPreviewListeners;
+
+ /**
+ * Returns the index of a specific tab or throws a {@link NoSuchElementException}, if the model
+ * does not contain the given tab.
+ *
+ * @param tab
+ * The tab, whose index should be returned, as an instance of the class {@link Tab}. The
+ * tab may not be null
+ * @return The index of the given tab as an {@link Integer} value
+ */
+ private int indexOfOrThrowException(@NonNull final Tab tab) {
+ int index = indexOf(tab);
+ ensureNotEqual(index, -1, "No such tab: " + tab, NoSuchElementException.class);
+ return index;
+ }
+
+ /**
+ * Sets, whether the tab switcher is currently shown, or not.
+ *
+ * @param shown
+ * True, if the tab switcher is currently shown, false otherwise
+ * @return True, if the visibility of the tab switcher has been changed, false otherwise
+ */
+ private boolean setSwitcherShown(final boolean shown) {
+ if (switcherShown != shown) {
+ switcherShown = shown;
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Notifies the listeners, that the log level has been changed.
+ *
+ * @param logLevel
+ * The log level, which has been set, as a value of the enum {@link LogLevel}. The log
+ * level may not be null
+ */
+ private void notifyOnLogLevelChanged(@NonNull final LogLevel logLevel) {
+ for (Listener listener : listeners) {
+ listener.onLogLevelChanged(logLevel);
+ }
+ }
+
+ /**
+ * Notifies the listeners, that the decorator has been changed.
+ *
+ * @param decorator
+ * The decorator, which has been set, as an instance of the class {@link
+ * TabSwitcherDecorator}. The decorator may not be null
+ */
+ private void notifyOnDecoratorChanged(@NonNull final TabSwitcherDecorator decorator) {
+ for (Listener listener : listeners) {
+ listener.onDecoratorChanged(decorator);
+ }
+ }
+
+ /**
+ * Notifies the listeners, that the tab switcher has been shown.
+ */
+ private void notifyOnSwitcherShown() {
+ for (Listener listener : listeners) {
+ listener.onSwitcherShown();
+ }
+ }
+
+ /**
+ * Notifies the listeners, that the tab switcher has been shown.
+ */
+ private void notifyOnSwitcherHidden() {
+ for (Listener listener : listeners) {
+ listener.onSwitcherHidden();
+ }
+ }
+
+ /**
+ * Notifies the listeners, that the currently selected tab has been changed.
+ *
+ * @param previousIndex
+ * The index of the previously selected tab as an {@link Integer} value or -1, if no tab
+ * was selected
+ * @param index
+ * The index of the tab, which has been selected, as an {@link Integer} value or -1, if
+ * no tab has been selected
+ * @param tab
+ * The tab, which has been selected, as an instance of the class {@link Tab} or null, if
+ * no tab has been selected
+ * @param switcherHidden
+ * True, if selecting the tab caused the tab switcher to be hidden, false otherwise
+ */
+ private void notifyOnSelectionChanged(final int previousIndex, final int index,
+ @Nullable final Tab tab, final boolean switcherHidden) {
+ for (Listener listener : listeners) {
+ listener.onSelectionChanged(previousIndex, index, tab, switcherHidden);
+ }
+ }
+
+ /**
+ * Notifies the listeners, that a specific tab has been added to the model.
+ *
+ * @param index
+ * The index, the tab has been added at, as an {@link Integer} value
+ * @param tab
+ * The tab, which has been added, as an instance of the class {@link Tab}. The tab may
+ * not be null
+ * @param previousSelectedTabIndex
+ * The index of the previously selected tab as an {@link Integer} value or -1, if no tab
+ * was selected
+ * @param selectedTabIndex
+ * The index of the currently selected tab as an {@link Integer} value or -1, if the tab
+ * switcher does not contain any tabs
+ * @param switcherVisibilityChanged
+ * True, if adding the tab caused the visibility of the tab switcher to be changed,
+ * false otherwise
+ * @param animation
+ * The animation, which has been used to add the tab, as an instance of the class {@link
+ * Animation}. The animation may not be null
+ */
+ private void notifyOnTabAdded(final int index, @NonNull final Tab tab,
+ final int previousSelectedTabIndex, final int selectedTabIndex,
+ final boolean switcherVisibilityChanged,
+ @NonNull final Animation animation) {
+ for (Listener listener : listeners) {
+ listener.onTabAdded(index, tab, previousSelectedTabIndex, selectedTabIndex,
+ switcherVisibilityChanged, animation);
+ }
+ }
+
+ /**
+ * Notifies the listeners, that multiple tabs have been added to the model.
+ *
+ * @param index
+ * The index of the tab, which has been added, as an {@link Integer} value
+ * @param tabs
+ * An array, which contains the tabs, which have been added, as an array of the type
+ * {@link Tab}. The array may not be null
+ * @param previousSelectedTabIndex
+ * The index of the previously selected tab as an {@link Integer} value or -1, if no tab
+ * was selected
+ * @param selectedTabIndex
+ * The index of the currently selected tab as an {@link Integer} value or -1, if the tab
+ * switcher does not contain any tabs
+ * @param animation
+ * The animation, which has been used to add the tabs, as an instance of the class
+ * {@link Animation}. The animation may not be null
+ */
+ private void notifyOnAllTabsAdded(final int index, @NonNull final Tab[] tabs,
+ final int previousSelectedTabIndex,
+ final int selectedTabIndex,
+ @NonNull final Animation animation) {
+ for (Listener listener : listeners) {
+ listener.onAllTabsAdded(index, tabs, previousSelectedTabIndex, selectedTabIndex,
+ animation);
+ }
+ }
+
+ /**
+ * Notifies the listeners, that a tab has been removed from the model.
+ *
+ * @param index
+ * The index of the tab, which has been removed, as an {@link Integer} value
+ * @param tab
+ * The tab, which has been removed, as an instance of the class {@link Tab}. The tab may
+ * not be null
+ * @param previousSelectedTabIndex
+ * The index of the previously selected tab as an {@link Integer} value or -1, if no tab
+ * was selected
+ * @param selectedTabIndex
+ * The index of the currently selected tab as an {@link Integer} value or -1, if the tab
+ * switcher does not contain any tabs
+ * @param animation
+ * The animation, which has been used to remove the tab, as an instance of the class
+ * {@link Animation}. The animation may not be null
+ */
+ private void notifyOnTabRemoved(final int index, @NonNull final Tab tab,
+ final int previousSelectedTabIndex, final int selectedTabIndex,
+ @NonNull final Animation animation) {
+ for (Listener listener : listeners) {
+ listener.onTabRemoved(index, tab, previousSelectedTabIndex, selectedTabIndex,
+ animation);
+ }
+ }
+
+ /**
+ * Notifies the listeners, that all tabs have been removed.
+ *
+ * @param tabs
+ * An array, which contains the tabs, which have been removed, as an array of the type
+ * {@link Tab} or an empty array, if no tabs have been removed
+ * @param animation
+ * The animation, which has been used to remove the tabs, as an instance of the class
+ * {@link Animation}. The animation may not be null
+ */
+ private void notifyOnAllTabsRemoved(@NonNull final Tab[] tabs,
+ @NonNull final Animation animation) {
+ for (Listener listener : listeners) {
+ listener.onAllTabsRemoved(tabs, animation);
+ }
+ }
+
+ /**
+ * Notifies the listeners, that the padding has been changed.
+ *
+ * @param left
+ * The left padding, which has been set, in pixels as an {@link Integer} value
+ * @param top
+ * The top padding, which has been set, in pixels as an {@link Integer} value
+ * @param right
+ * The right padding, which has been set, in pixels as an {@link Integer} value
+ * @param bottom
+ * The bottom padding, which has been set, in pixels as an {@link Integer} value
+ */
+ private void notifyOnPaddingChanged(final int left, final int top, final int right,
+ final int bottom) {
+ for (Listener listener : listeners) {
+ listener.onPaddingChanged(left, top, right, bottom);
+ }
+ }
+
+ /**
+ * Notifies the listeners, that the default icon of a tab has been changed.
+ *
+ * @param icon
+ * The icon, which has been set, as an instance of the class {@link Drawable} or null,
+ * if no icon is set
+ */
+ private void notifyOnTabIconChanged(@Nullable final Drawable icon) {
+ for (Listener listener : listeners) {
+ listener.onTabIconChanged(icon);
+ }
+ }
+
+ /**
+ * Notifies the listeners, that the default background color of a tab has been changed.
+ *
+ * @param colorStateList
+ * The color state list, which has been set, as an instance of the class {@link
+ * ColorStateList} or null, if the default color should be used
+ */
+ private void notifyOnTabBackgroundColorChanged(@Nullable final ColorStateList colorStateList) {
+ for (Listener listener : listeners) {
+ listener.onTabBackgroundColorChanged(colorStateList);
+ }
+ }
+
+ /**
+ * Notifies the listeners, that the default text color of a tab's title has been changed.
+ *
+ * @param colorStateList
+ * The color state list, which has been set, as an instance of the class {@link
+ * ColorStateList} or null, if the default color should be used
+ */
+ private void notifyOnTabTitleColorChanged(@Nullable final ColorStateList colorStateList) {
+ for (Listener listener : listeners) {
+ listener.onTabTitleColorChanged(colorStateList);
+ }
+ }
+
+ /**
+ * Notifies the listeners, that the icon of a tab's close button has been changed.
+ *
+ * @param icon
+ * The icon, which has been set, as an instance of the class {@link Drawable} or null,
+ * if the default icon should be used
+ */
+ private void notifyOnTabCloseButtonIconChanged(@Nullable final Drawable icon) {
+ for (Listener listener : listeners) {
+ listener.onTabCloseButtonIconChanged(icon);
+ }
+ }
+
+ /**
+ * Notifies the listeners, that it has been changed, whether the toolbars should be shown, when
+ * the tab switcher is shown, or not.
+ *
+ * @param visible
+ * True, if the toolbars should be shown, when the tab switcher is shown, false
+ * otherwise
+ */
+ private void notifyOnToolbarVisibilityChanged(final boolean visible) {
+ for (Listener listener : listeners) {
+ listener.onToolbarVisibilityChanged(visible);
+ }
+ }
+
+ /**
+ * Notifies the listeners, that the title of the toolbar, which is shown, when the tab switcher
+ * is shown, has been changed.
+ *
+ * @param title
+ * The title, which has been set, as an instance of the type {@link CharSequence} or
+ * null, if no title is set
+ */
+ private void notifyOnToolbarTitleChanged(@Nullable final CharSequence title) {
+ for (Listener listener : listeners) {
+ listener.onToolbarTitleChanged(title);
+ }
+ }
+
+ /**
+ * Notifies the listeners, that the menu of the toolbar, which is shown, when the tab switcher
+ * is shown, has been inflated.
+ *
+ * @param resourceId
+ * The resource id of the menu, which has been inflated, as an {@link Integer} value.
+ * The resource id must correspond to a valid menu resource
+ * @param menuItemClickListener
+ * The listener, which has been registered to be notified, when an item of the menu has
+ * been clicked, as an instance of the type OnMenuItemClickListener or null, if no
+ * listener should be notified
+ */
+ private void notifyOnToolbarMenuInflated(@MenuRes final int resourceId,
+ @Nullable final OnMenuItemClickListener menuItemClickListener) {
+ for (Listener listener : listeners) {
+ listener.onToolbarMenuInflated(resourceId, menuItemClickListener);
+ }
+ }
+
+ /**
+ * Notifies the listeners, that the navigation icon of the toolbar, which is shown, when the tab
+ * switcher is shown, has been changed.
+ *
+ * @param icon
+ * The navigation icon, which has been set, as an instance of the class {@link Drawable}
+ * or null, if no navigation icon is set
+ * @param clickListener
+ * The listener, which should be notified, when the navigation item has been clicked, as
+ * an instance of the type {@link OnClickListener} or null, if no listener should be
+ * notified
+ */
+ private void notifyOnToolbarNavigationIconChanged(@Nullable final Drawable icon,
+ @Nullable final OnClickListener clickListener) {
+ for (Listener listener : listeners) {
+ listener.onToolbarNavigationIconChanged(icon, clickListener);
+ }
+ }
+
+ /**
+ * Creates a new model of a {@link TabSwitcher}.
+ *
+ * @param tabSwitcher
+ * The tab switcher, the model belongs to, as an instance of the class {@link
+ * ViewGroup}. The parent may not be null
+ */
+ public TabSwitcherModel(@NonNull final TabSwitcher tabSwitcher) {
+ ensureNotNull(tabSwitcher, "The tab switcher may not be null");
+ this.tabSwitcher = tabSwitcher;
+ this.listeners = new LinkedHashSet<>();
+ this.firstVisibleTabIndex = -1;
+ this.firstVisibleTabPosition = -1;
+ this.logLevel = LogLevel.INFO;
+ this.tabs = new ArrayList<>();
+ this.switcherShown = false;
+ this.selectedTab = null;
+ this.decorator = null;
+ this.childRecyclerAdapter = null;
+ this.padding = new int[]{0, 0, 0, 0};
+ this.tabIconId = -1;
+ this.tabIconBitmap = null;
+ this.tabBackgroundColor = null;
+ this.tabTitleTextColor = null;
+ this.tabCloseButtonIconId = -1;
+ this.tabCloseButtonIconBitmap = null;
+ this.showToolbars = false;
+ this.toolbarTitle = null;
+ this.toolbarNavigationIcon = null;
+ this.toolbarNavigationIconListener = null;
+ this.toolbarMenuId = -1;
+ this.toolbarMenuItemListener = null;
+ this.tabCloseListeners = new LinkedHashSet<>();
+ this.tabPreviewListeners = new LinkedHashSet<>();
+ }
+
+ /**
+ * Adds a new listener, which should be notified about the model's events.
+ *
+ * @param listener
+ * The listener, which should be added, as an instance of the type {@link Listener}. The
+ * listener may not be null
+ */
+ public final void addListener(@NonNull final Listener listener) {
+ ensureNotNull(listener, "The listener may not be null");
+ listeners.add(listener);
+ }
+
+ /**
+ * Removes a specific listener, which should not be notified about the model's events, anymore.
+ *
+ * @param listener
+ * The listener, which should be removed, as an instance of the type {@link Listener}.
+ * The listener may not be null
+ */
+ public final void removeListener(@NonNull final Listener listener) {
+ ensureNotNull(listener, "The listener may not be null");
+ listeners.remove(listener);
+ }
+
+ /**
+ * Returns the index of the first visible tab.
+ *
+ * @return The index of the first visible tab as an {@link Integer} value or -1, if the index is
+ * unknown
+ */
+ public final int getFirstVisibleTabIndex() {
+ return firstVisibleTabIndex;
+ }
+
+ /**
+ * Sets the index of the first visible tab.
+ *
+ * @param firstVisibleTabIndex
+ * The index of the first visible tab, which should be set, as an {@link Integer} value
+ * or -1, if the index is unknown
+ */
+ public final void setFirstVisibleTabIndex(final int firstVisibleTabIndex) {
+ this.firstVisibleTabIndex = firstVisibleTabIndex;
+ }
+
+ /**
+ * Returns the position of the first visible tab.
+ *
+ * @return The position of the first visible tab as a {@link Float} value or -1, if the position
+ * is unknown
+ */
+ public final float getFirstVisibleTabPosition() {
+ return firstVisibleTabPosition;
+ }
+
+ /**
+ * Sets the position of the first visible tab.
+ *
+ * @param firstVisibleTabPosition
+ * The position of the first visible tab, which should be set, as a {@link Float} value
+ * or -1, if the position is unknown
+ */
+ public final void setFirstVisibleTabPosition(final float firstVisibleTabPosition) {
+ this.firstVisibleTabPosition = firstVisibleTabPosition;
+ }
+
+ /**
+ * Returns the listener, which is notified, when the navigation icon of the toolbar, which is
+ * shown, when the tab switcher is shown, has been clicked.
+ *
+ * @return The listener, which is notified, when the navigation icon of the toolbar, which is
+ * shown, when the tab switcher is shown, has been clicked as an instance of the type {@link
+ * OnClickListener} or null, if no listener should be notified
+ */
+ @Nullable
+ public final OnClickListener getToolbarNavigationIconListener() {
+ return toolbarNavigationIconListener;
+ }
+
+ /**
+ * Returns the resource id of the menu of the toolbar, which is shown, when the tab switcher is
+ * shown.
+ *
+ * @return The resource id of the menu of the toolbar, which is shown, when the tab switcher is
+ * shown, as an {@link Integer} value. The resource id must correspond to a valid menu resource
+ */
+ @MenuRes
+ public final int getToolbarMenuId() {
+ return toolbarMenuId;
+ }
+
+ /**
+ * Returns the listener, which is notified, when an item of the menu of the toolbar, which is
+ * shown, when the tab switcher is shown, has been clicked.
+ *
+ * @return The listener, which is notified, when an item of the menu of the toolbar, which is
+ * shown, when the tab switcher is shown, has been clicked as an instance of the type
+ * OnMenuItemClickListener or null, if no listener should be notified
+ */
+ @Nullable
+ public final OnMenuItemClickListener getToolbarMenuItemListener() {
+ return toolbarMenuItemListener;
+ }
+
+ /**
+ * Returns the listeners, which should be notified, when a tab is about to be closed by clicking
+ * its close button.
+ *
+ * @return A set, which contains the listeners, which should be notified, when a tab is about to
+ * be closed by clicking its close button, as an instance of the type {@link Set} or an empty
+ * set, if no listeners should be notified
+ */
+ @NonNull
+ public final Set getTabCloseListeners() {
+ return tabCloseListeners;
+ }
+
+ /**
+ * Returns the listeners, which should be notified, when the previews of tabs are about to be
+ * loaded.
+ *
+ * @return A set, which contains the listeners, which should be notified, when the previews of
+ * tabs are about to be loaded, as an instance of the type {@link Set} or an empty set, if no
+ * listeners should be notified
+ */
+ @NonNull
+ public final Set getTabPreviewListeners() {
+ return tabPreviewListeners;
+ }
+
+ /**
+ * Returns the adapter, which allows to inflate the child views of tabs.
+ *
+ * @return The adapter, which allows to inflate the child views of tabs, as an instance of the
+ * class {@link ChildRecyclerAdapter}
+ */
+ public final ChildRecyclerAdapter getChildRecyclerAdapter() {
+ return childRecyclerAdapter;
+ }
+
+ @NonNull
+ @Override
+ public final Context getContext() {
+ return tabSwitcher.getContext();
+ }
+
+ @Override
+ public final void setDecorator(@NonNull final TabSwitcherDecorator decorator) {
+ ensureNotNull(decorator, "The decorator may not be null");
+ this.decorator = decorator;
+ this.childRecyclerAdapter = new ChildRecyclerAdapter(tabSwitcher, decorator);
+ notifyOnDecoratorChanged(decorator);
+ }
+
+ @Override
+ public final TabSwitcherDecorator getDecorator() {
+ return decorator;
+ }
+
+ @NonNull
+ @Override
+ public final LogLevel getLogLevel() {
+ return logLevel;
+ }
+
+ @Override
+ public final void setLogLevel(@NonNull final LogLevel logLevel) {
+ ensureNotNull(logLevel, "The log level may not be null");
+ this.logLevel = logLevel;
+ notifyOnLogLevelChanged(logLevel);
+ }
+
+ @Override
+ public final boolean isEmpty() {
+ return tabs.isEmpty();
+ }
+
+ @Override
+ public final int getCount() {
+ return tabs.size();
+ }
+
+ @NonNull
+ @Override
+ public final Tab getTab(final int index) {
+ return tabs.get(index);
+ }
+
+ @Override
+ public final int indexOf(@NonNull final Tab tab) {
+ ensureNotNull(tab, "The tab may not be null");
+ return tabs.indexOf(tab);
+ }
+
+ @Override
+ public final void addTab(@NonNull Tab tab) {
+ addTab(tab, getCount());
+ }
+
+ @Override
+ public final void addTab(@NonNull final Tab tab, final int index) {
+ addTab(tab, index, new SwipeAnimation.Builder().create());
+ }
+
+ @Override
+ public final void addTab(@NonNull final Tab tab, final int index,
+ @NonNull final Animation animation) {
+ ensureNotNull(tab, "The tab may not be null");
+ ensureNotNull(animation, "The animation may not be null");
+ tabs.add(index, tab);
+ int previousSelectedTabIndex = getSelectedTabIndex();
+ int selectedTabIndex = previousSelectedTabIndex;
+ boolean switcherVisibilityChanged = false;
+
+ if (previousSelectedTabIndex == -1) {
+ selectedTab = tab;
+ selectedTabIndex = index;
+ }
+
+ if (animation instanceof RevealAnimation) {
+ selectedTab = tab;
+ selectedTabIndex = index;
+ switcherVisibilityChanged = setSwitcherShown(false);
+ }
+
+ if (animation instanceof PeekAnimation) {
+ switcherVisibilityChanged = setSwitcherShown(true);
+ }
+
+ notifyOnTabAdded(index, tab, previousSelectedTabIndex, selectedTabIndex,
+ switcherVisibilityChanged, animation);
+ }
+
+ @Override
+ public final void addAllTabs(@NonNull final Collection extends Tab> tabs) {
+ addAllTabs(tabs, getCount());
+ }
+
+ @Override
+ public final void addAllTabs(@NonNull final Collection extends Tab> tabs, final int index) {
+ addAllTabs(tabs, index, new SwipeAnimation.Builder().create());
+ }
+
+ @Override
+ public final void addAllTabs(@NonNull final Collection extends Tab> tabs, final int index,
+ @NonNull final Animation animation) {
+ ensureNotNull(tabs, "The collection may not be null");
+ Tab[] array = new Tab[tabs.size()];
+ tabs.toArray(array);
+ addAllTabs(array, index, animation);
+ }
+
+ @Override
+ public final void addAllTabs(@NonNull final Tab[] tabs) {
+ addAllTabs(tabs, getCount());
+ }
+
+ @Override
+ public final void addAllTabs(@NonNull final Tab[] tabs, final int index) {
+ addAllTabs(tabs, index, new SwipeAnimation.Builder().create());
+ }
+
+ @Override
+ public final void addAllTabs(@NonNull final Tab[] tabs, final int index,
+ @NonNull final Animation animation) {
+ ensureNotNull(tabs, "The array may not be null");
+ ensureNotNull(animation, "The animation may not be null");
+
+ if (tabs.length > 0) {
+ int previousSelectedTabIndex = getSelectedTabIndex();
+ int selectedTabIndex = previousSelectedTabIndex;
+
+ for (int i = 0; i < tabs.length; i++) {
+ Tab tab = tabs[i];
+ this.tabs.add(index + i, tab);
+ }
+
+ if (previousSelectedTabIndex == -1) {
+ selectedTabIndex = 0;
+ selectedTab = tabs[selectedTabIndex];
+ }
+
+ notifyOnAllTabsAdded(index, tabs, previousSelectedTabIndex, selectedTabIndex,
+ animation);
+ }
+ }
+
+ @Override
+ public final void removeTab(@NonNull final Tab tab) {
+ removeTab(tab, new SwipeAnimation.Builder().create());
+ }
+
+ @Override
+ public final void removeTab(@NonNull final Tab tab, @NonNull final Animation animation) {
+ ensureNotNull(tab, "The tab may not be null");
+ ensureNotNull(animation, "The animation may not be null");
+ int index = indexOfOrThrowException(tab);
+ int previousSelectedTabIndex = getSelectedTabIndex();
+ int selectedTabIndex = previousSelectedTabIndex;
+ tabs.remove(index);
+
+ if (isEmpty()) {
+ selectedTabIndex = -1;
+ selectedTab = null;
+ } else if (index == previousSelectedTabIndex) {
+ if (index > 0) {
+ selectedTabIndex = index - 1;
+ }
+
+ selectedTab = getTab(selectedTabIndex);
+ }
+
+ notifyOnTabRemoved(index, tab, previousSelectedTabIndex, selectedTabIndex, animation);
+
+ }
+
+ @Override
+ public final void clear() {
+ clear(new SwipeAnimation.Builder().create());
+ }
+
+ @Override
+ public final void clear(@NonNull final Animation animation) {
+ ensureNotNull(animation, "The animation may not be null");
+ Tab[] result = new Tab[tabs.size()];
+ tabs.toArray(result);
+ tabs.clear();
+ notifyOnAllTabsRemoved(result, animation);
+ selectedTab = null;
+ }
+
+ @Override
+ public final boolean isSwitcherShown() {
+ return switcherShown;
+ }
+
+ @Override
+ public final void showSwitcher() {
+ setSwitcherShown(true);
+ notifyOnSwitcherShown();
+ }
+
+ @Override
+ public final void hideSwitcher() {
+ setSwitcherShown(false);
+ notifyOnSwitcherHidden();
+ }
+
+ @Override
+ public final void toggleSwitcherVisibility() {
+ if (isSwitcherShown()) {
+ hideSwitcher();
+ } else {
+ showSwitcher();
+ }
+ }
+
+ @Nullable
+ @Override
+ public final Tab getSelectedTab() {
+ return selectedTab;
+ }
+
+ @Override
+ public final int getSelectedTabIndex() {
+ return selectedTab != null ? indexOf(selectedTab) : -1;
+ }
+
+ @Override
+ public final void selectTab(@NonNull final Tab tab) {
+ ensureNotNull(tab, "The tab may not be null");
+ int previousIndex = getSelectedTabIndex();
+ int index = indexOfOrThrowException(tab);
+ selectedTab = tab;
+ boolean switcherHidden = setSwitcherShown(false);
+ notifyOnSelectionChanged(previousIndex, index, tab, switcherHidden);
+ }
+
+ @Override
+ public final Iterator iterator() {
+ return tabs.iterator();
+ }
+
+ @Override
+ public final void setPadding(final int left, final int top, final int right, final int bottom) {
+ padding = new int[]{left, top, right, bottom};
+ notifyOnPaddingChanged(left, top, right, bottom);
+ }
+
+ @Override
+ public final int getPaddingLeft() {
+ return padding[0];
+ }
+
+ @Override
+ public final int getPaddingTop() {
+ return padding[1];
+ }
+
+ @Override
+ public final int getPaddingRight() {
+ return padding[2];
+ }
+
+ @Override
+ public final int getPaddingBottom() {
+ return padding[3];
+ }
+
+ @Override
+ public final int getPaddingStart() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ return tabSwitcher.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL ?
+ getPaddingRight() : getPaddingLeft();
+ }
+
+ return getPaddingLeft();
+ }
+
+ @Override
+ public final int getPaddingEnd() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ return tabSwitcher.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL ?
+ getPaddingLeft() : getPaddingRight();
+ }
+
+ return getPaddingRight();
+ }
+
+ @Nullable
+ @Override
+ public final Drawable getTabIcon() {
+ if (tabIconId != -1) {
+ return ContextCompat.getDrawable(getContext(), tabIconId);
+ } else {
+ return tabIconBitmap != null ?
+ new BitmapDrawable(getContext().getResources(), tabIconBitmap) : null;
+ }
+ }
+
+ @Override
+ public final void setTabIcon(@DrawableRes final int resourceId) {
+ this.tabIconId = resourceId;
+ this.tabIconBitmap = null;
+ notifyOnTabIconChanged(getTabIcon());
+ }
+
+ @Override
+ public final void setTabIcon(@Nullable final Bitmap icon) {
+ this.tabIconId = -1;
+ this.tabIconBitmap = icon;
+ notifyOnTabIconChanged(getTabIcon());
+ }
+
+ @Nullable
+ @Override
+ public final ColorStateList getTabBackgroundColor() {
+ return tabBackgroundColor;
+ }
+
+ @Override
+ public final void setTabBackgroundColor(@ColorInt final int color) {
+ setTabBackgroundColor(color != -1 ? ColorStateList.valueOf(color) : null);
+ }
+
+ @Override
+ public final void setTabBackgroundColor(@Nullable final ColorStateList colorStateList) {
+ this.tabBackgroundColor = colorStateList;
+ notifyOnTabBackgroundColorChanged(colorStateList);
+ }
+
+ @Nullable
+ @Override
+ public final ColorStateList getTabTitleTextColor() {
+ return tabTitleTextColor;
+ }
+
+ @Override
+ public final void setTabTitleTextColor(@ColorInt final int color) {
+ setTabTitleTextColor(color != -1 ? ColorStateList.valueOf(color) : null);
+ }
+
+ @Override
+ public final void setTabTitleTextColor(@Nullable final ColorStateList colorStateList) {
+ this.tabTitleTextColor = colorStateList;
+ notifyOnTabTitleColorChanged(colorStateList);
+ }
+
+ @Nullable
+ @Override
+ public final Drawable getTabCloseButtonIcon() {
+ if (tabCloseButtonIconId != -1) {
+ return ContextCompat.getDrawable(getContext(), tabCloseButtonIconId);
+ } else {
+ return tabCloseButtonIconBitmap != null ?
+ new BitmapDrawable(getContext().getResources(), tabCloseButtonIconBitmap) :
+ null;
+ }
+ }
+
+ @Override
+ public final void setTabCloseButtonIcon(@DrawableRes final int resourceId) {
+ tabCloseButtonIconId = resourceId;
+ tabCloseButtonIconBitmap = null;
+ notifyOnTabCloseButtonIconChanged(getTabCloseButtonIcon());
+ }
+
+ @Override
+ public final void setTabCloseButtonIcon(@Nullable final Bitmap icon) {
+ tabCloseButtonIconId = -1;
+ tabCloseButtonIconBitmap = icon;
+ notifyOnTabCloseButtonIconChanged(getTabCloseButtonIcon());
+ }
+
+ @Override
+ public final boolean areToolbarsShown() {
+ return showToolbars;
+ }
+
+ @Override
+ public final void showToolbars(final boolean show) {
+ this.showToolbars = show;
+ notifyOnToolbarVisibilityChanged(show);
+ }
+
+ @Nullable
+ @Override
+ public final CharSequence getToolbarTitle() {
+ return toolbarTitle;
+ }
+
+ @Override
+ public void setToolbarTitle(@StringRes final int resourceId) {
+ setToolbarTitle(getContext().getText(resourceId));
+ }
+
+ @Override
+ public final void setToolbarTitle(@Nullable final CharSequence title) {
+ this.toolbarTitle = title;
+ notifyOnToolbarTitleChanged(title);
+ }
+
+ @Nullable
+ @Override
+ public final Drawable getToolbarNavigationIcon() {
+ return toolbarNavigationIcon;
+ }
+
+ @Override
+ public final void setToolbarNavigationIcon(@DrawableRes final int resourceId,
+ @Nullable final OnClickListener listener) {
+ setToolbarNavigationIcon(ContextCompat.getDrawable(getContext(), resourceId), listener);
+ }
+
+ @Override
+ public final void setToolbarNavigationIcon(@Nullable final Drawable icon,
+ @Nullable final OnClickListener listener) {
+ this.toolbarNavigationIcon = icon;
+ this.toolbarNavigationIconListener = listener;
+ notifyOnToolbarNavigationIconChanged(icon, listener);
+ }
+
+ @Override
+ public final void inflateToolbarMenu(@MenuRes final int resourceId,
+ @Nullable final OnMenuItemClickListener listener) {
+ this.toolbarMenuId = resourceId;
+ this.toolbarMenuItemListener = listener;
+ notifyOnToolbarMenuInflated(resourceId, listener);
+ }
+
+ @Override
+ public final void addCloseTabListener(@NonNull final TabCloseListener listener) {
+ ensureNotNull(listener, "The listener may not be null");
+ tabCloseListeners.add(listener);
+ }
+
+ @Override
+ public final void removeCloseTabListener(@NonNull final TabCloseListener listener) {
+ ensureNotNull(listener, "The listener may not be null");
+ tabCloseListeners.remove(listener);
+ }
+
+ @Override
+ public final void addTabPreviewListener(@NonNull final TabPreviewListener listener) {
+ ensureNotNull(listener, "The listener may not be null");
+ tabPreviewListeners.add(listener);
+ }
+
+ @Override
+ public final void removeTabPreviewListener(@NonNull final TabPreviewListener listener) {
+ ensureNotNull(listener, "The listener may not be null");
+ tabPreviewListeners.remove(listener);
+ }
+
+ @Override
+ public final void saveInstanceState(@NonNull final Bundle outState) {
+ outState.putSerializable(LOG_LEVEL_EXTRA, logLevel);
+ outState.putParcelableArrayList(TABS_EXTRA, tabs);
+ outState.putBoolean(SWITCHER_SHOWN_EXTRA, switcherShown);
+ outState.putParcelable(SELECTED_TAB_EXTRA, selectedTab);
+ outState.putIntArray(PADDING_EXTRA, padding);
+ outState.putInt(TAB_ICON_ID_EXTRA, tabIconId);
+ outState.putParcelable(TAB_ICON_BITMAP_EXTRA, tabIconBitmap);
+ outState.putParcelable(TAB_BACKGROUND_COLOR_EXTRA, tabBackgroundColor);
+ outState.putParcelable(TAB_TITLE_TEXT_COLOR_EXTRA, tabTitleTextColor);
+ outState.putInt(TAB_CLOSE_BUTTON_ICON_ID_EXTRA, tabCloseButtonIconId);
+ outState.putParcelable(TAB_CLOSE_BUTTON_ICON_BITMAP_EXTRA, tabCloseButtonIconBitmap);
+ outState.putBoolean(SHOW_TOOLBARS_EXTRA, showToolbars);
+ outState.putCharSequence(TOOLBAR_TITLE_EXTRA, toolbarTitle);
+ childRecyclerAdapter.saveInstanceState(outState);
+ }
+
+ @Override
+ public final void restoreInstanceState(@Nullable final Bundle savedInstanceState) {
+ if (savedInstanceState != null) {
+ firstVisibleTabIndex = savedInstanceState.getInt(FIRST_VISIBLE_TAB_INDEX_EXTRA, -1);
+ firstVisibleTabPosition =
+ savedInstanceState.getFloat(FIRST_VISIBLE_TAB_POSITION_EXTRA, -1);
+ logLevel = (LogLevel) savedInstanceState.getSerializable(LOG_LEVEL_EXTRA);
+ tabs = savedInstanceState.getParcelableArrayList(TABS_EXTRA);
+ switcherShown = savedInstanceState.getBoolean(SWITCHER_SHOWN_EXTRA);
+ selectedTab = savedInstanceState.getParcelable(SELECTED_TAB_EXTRA);
+ padding = savedInstanceState.getIntArray(PADDING_EXTRA);
+ tabIconId = savedInstanceState.getInt(TAB_ICON_ID_EXTRA);
+ tabIconBitmap = savedInstanceState.getParcelable(TAB_ICON_BITMAP_EXTRA);
+ tabBackgroundColor = savedInstanceState.getParcelable(TAB_BACKGROUND_COLOR_EXTRA);
+ tabTitleTextColor = savedInstanceState.getParcelable(TAB_TITLE_TEXT_COLOR_EXTRA);
+ tabCloseButtonIconId = savedInstanceState.getInt(TAB_CLOSE_BUTTON_ICON_ID_EXTRA);
+ tabCloseButtonIconBitmap =
+ savedInstanceState.getParcelable(TAB_CLOSE_BUTTON_ICON_BITMAP_EXTRA);
+ showToolbars = savedInstanceState.getBoolean(SHOW_TOOLBARS_EXTRA);
+ toolbarTitle = savedInstanceState.getCharSequence(TOOLBAR_TITLE_EXTRA);
+ childRecyclerAdapter.restoreInstanceState(savedInstanceState);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Tag.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Tag.java
new file mode 100755
index 0000000..5ee6081
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/model/Tag.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.model;
+
+import android.support.annotation.NonNull;
+
+import de.mrapp.android.tabswitcher.TabSwitcher;
+
+import static de.mrapp.android.util.Condition.ensureNotNull;
+
+/**
+ * A tag, which allows to store the properties of the tabs of a {@link TabSwitcher}.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class Tag implements Cloneable {
+
+ /**
+ * The position of the tab on the dragging axis.
+ */
+ private float position;
+
+ /**
+ * The state of the tab.
+ */
+ private State state;
+
+ /**
+ * True, if the tab is currently being closed, false otherwise
+ */
+ private boolean closing;
+
+ /**
+ * Creates a new tag, which allows to store the properties of the tabs of a {@link
+ * TabSwitcher}.
+ */
+ public Tag() {
+ setPosition(Float.NaN);
+ setState(State.HIDDEN);
+ setClosing(false);
+ }
+
+ /**
+ * Returns the position of the tab on the dragging axis.
+ *
+ * @return The position of the tab as a {@link Float} value
+ */
+ public final float getPosition() {
+ return position;
+ }
+
+ /**
+ * Sets the position of the tab on the dragging axis.
+ *
+ * @param position
+ * The position, which should be set, as a {@link Float} value
+ */
+ public final void setPosition(final float position) {
+ this.position = position;
+ }
+
+ /**
+ * Returns the state of the tab.
+ *
+ * @return The state of the tab as a value of the enum {@link State}. The state may not be null
+ */
+ @NonNull
+ public final State getState() {
+ return state;
+ }
+
+ /**
+ * Sets the state of the tab.
+ *
+ * @param state
+ * The state, which should be set, as a value of the enum {@link State}. The state may
+ * not be null
+ */
+ public final void setState(@NonNull final State state) {
+ ensureNotNull(state, "The state may not be null");
+ this.state = state;
+ }
+
+ /**
+ * Returns, whether the tab is currently being closed, or not.
+ *
+ * @return True, if the tab is currently being closed, false otherwise
+ */
+ public final boolean isClosing() {
+ return closing;
+ }
+
+ /**
+ * Sets, whether the tab is currently being closed, or not.
+ *
+ * @param closing
+ * True, if the tab is currently being closed, false otherwise
+ */
+ public final void setClosing(final boolean closing) {
+ this.closing = closing;
+ }
+
+ @Override
+ public final Tag clone() {
+ Tag clone;
+
+ try {
+ clone = (Tag) super.clone();
+ } catch (ClassCastException | CloneNotSupportedException e) {
+ clone = new Tag();
+ }
+
+ clone.position = position;
+ clone.state = state;
+ clone.closing = closing;
+ return clone;
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/view/TabSwitcherButton.java b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/view/TabSwitcherButton.java
new file mode 100755
index 0000000..7f9c7f7
--- /dev/null
+++ b/chrome-tabs/src/main/java/de/mrapp/android/tabswitcher/view/TabSwitcherButton.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright 2016 - 2017 Michael Rapp
+ *
+ * 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.mrapp.android.tabswitcher.view;
+
+import android.content.Context;
+import android.support.annotation.AttrRes;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v7.widget.AppCompatImageButton;
+import android.util.AttributeSet;
+
+import de.mrapp.android.tabswitcher.Animation;
+import de.mrapp.android.tabswitcher.R;
+import de.mrapp.android.tabswitcher.Tab;
+import de.mrapp.android.tabswitcher.TabSwitcher;
+import de.mrapp.android.tabswitcher.TabSwitcherListener;
+import de.mrapp.android.tabswitcher.drawable.TabSwitcherDrawable;
+import de.mrapp.android.util.ThemeUtil;
+import de.mrapp.android.util.ViewUtil;
+
+/**
+ * An image button, which allows to display the number of tabs, which are currently contained by a
+ * {@link TabSwitcher} by using a {@link TabSwitcherDrawable}. It must be registered at a {@link
+ * TabSwitcher} instance in order to keep the displayed count up to date. It therefore implements
+ * the interface {@link TabSwitcherListener}.
+ *
+ * @author Michael Rapp
+ * @since 0.1.0
+ */
+public class TabSwitcherButton extends AppCompatImageButton implements TabSwitcherListener {
+
+ /**
+ * The drawable, which is used by the image button.
+ */
+ private TabSwitcherDrawable drawable;
+
+ /**
+ * Initializes the view.
+ */
+ private void initialize() {
+ drawable = new TabSwitcherDrawable(getContext());
+ setImageDrawable(drawable);
+ ViewUtil.setBackground(this,
+ ThemeUtil.getDrawable(getContext(), R.attr.selectableItemBackgroundBorderless));
+ setContentDescription(null);
+ setClickable(true);
+ setFocusable(true);
+ }
+
+ /**
+ * Creates a new image button, which allows to display the number of tabs, which are currently
+ * contained by a {@link TabSwitcher}.
+ *
+ * @param context
+ * The context, which should be used by the view, as an instance of the class {@link
+ * Context}. The context may not be null
+ */
+ public TabSwitcherButton(@NonNull final Context context) {
+ this(context, null);
+ }
+
+ /**
+ * Creates a new image button, which allows to display the number of tabs, which are currently
+ * contained by a {@link TabSwitcher}.
+ *
+ * @param context
+ * The context, which should be used by the view, as an instance of the class {@link
+ * Context}. The context may not be null
+ * @param attributeSet
+ * The attribute set, the view's attributes should be obtained from, as an instance of
+ * the type {@link AttributeSet} or null, if no attributes should be obtained
+ */
+ public TabSwitcherButton(@NonNull final Context context,
+ @Nullable final AttributeSet attributeSet) {
+ super(context, attributeSet);
+ initialize();
+ }
+
+ /**
+ * Creates a new image button, which allows to display the number of tabs, which are currently
+ * contained by a {@link TabSwitcher}.
+ *
+ * @param context
+ * The context, which should be used by the view, as an instance of the class {@link
+ * Context}. The context may not be null
+ * @param attributeSet
+ * The attribute set, the view's attributes should be obtained from, as an instance of
+ * the type {@link AttributeSet} or null, if no attributes should be obtained
+ * @param defaultStyle
+ * 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 TabSwitcherButton(@NonNull final Context context,
+ @Nullable final AttributeSet attributeSet,
+ @AttrRes final int defaultStyle) {
+ super(context, attributeSet, defaultStyle);
+ initialize();
+ }
+
+ /**
+ * Updates the image button to display a specific value.
+ *
+ * @param count
+ * The value, which should be displayed, as an {@link Integer} value. The value must be
+ * at least 0
+ */
+ public final void setCount(final int count) {
+ drawable.setCount(count);
+ }
+
+ @Override
+ public final void onSwitcherShown(@NonNull final TabSwitcher tabSwitcher) {
+ drawable.onSwitcherShown(tabSwitcher);
+ }
+
+ @Override
+ public final void onSwitcherHidden(@NonNull final TabSwitcher tabSwitcher) {
+ drawable.onSwitcherHidden(tabSwitcher);
+ }
+
+ @Override
+ public final void onSelectionChanged(@NonNull final TabSwitcher tabSwitcher,
+ final int selectedTabIndex,
+ @Nullable final Tab selectedTab) {
+ drawable.onSelectionChanged(tabSwitcher, selectedTabIndex, selectedTab);
+ }
+
+ @Override
+ public final void onTabAdded(@NonNull final TabSwitcher tabSwitcher, final int index,
+ @NonNull final Tab tab, @NonNull final Animation animation) {
+ drawable.onTabAdded(tabSwitcher, index, tab, animation);
+ }
+
+ @Override
+ public final void onTabRemoved(@NonNull final TabSwitcher tabSwitcher, final int index,
+ @NonNull final Tab tab, @NonNull final Animation animation) {
+ drawable.onTabRemoved(tabSwitcher, index, tab, animation);
+ }
+
+ @Override
+ public final void onAllTabsRemoved(@NonNull final TabSwitcher tabSwitcher,
+ @NonNull final Tab[] tabs,
+ @NonNull final Animation animation) {
+ drawable.onAllTabsRemoved(tabSwitcher, tabs, animation);
+ }
+
+}
\ No newline at end of file
diff --git a/chrome-tabs/src/main/res/drawable-hdpi/phone_close_tab_icon.png b/chrome-tabs/src/main/res/drawable-hdpi/phone_close_tab_icon.png
new file mode 100755
index 0000000..b3faed4
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-hdpi/phone_close_tab_icon.png differ
diff --git a/chrome-tabs/src/main/res/drawable-hdpi/phone_tab_background.9.png b/chrome-tabs/src/main/res/drawable-hdpi/phone_tab_background.9.png
new file mode 100755
index 0000000..2f4bf05
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-hdpi/phone_tab_background.9.png differ
diff --git a/chrome-tabs/src/main/res/drawable-hdpi/phone_tab_border.9.png b/chrome-tabs/src/main/res/drawable-hdpi/phone_tab_border.9.png
new file mode 100755
index 0000000..a85f0f1
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-hdpi/phone_tab_border.9.png differ
diff --git a/chrome-tabs/src/main/res/drawable-hdpi/tab_switcher_drawable_background.png b/chrome-tabs/src/main/res/drawable-hdpi/tab_switcher_drawable_background.png
new file mode 100755
index 0000000..335deae
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-hdpi/tab_switcher_drawable_background.png differ
diff --git a/chrome-tabs/src/main/res/drawable-mdpi/ic_close_tab_18dp.png b/chrome-tabs/src/main/res/drawable-mdpi/ic_close_tab_18dp.png
new file mode 100755
index 0000000..70bbbf3
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-mdpi/ic_close_tab_18dp.png differ
diff --git a/chrome-tabs/src/main/res/drawable-mdpi/phone_tab_background.9.png b/chrome-tabs/src/main/res/drawable-mdpi/phone_tab_background.9.png
new file mode 100755
index 0000000..ffc021c
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-mdpi/phone_tab_background.9.png differ
diff --git a/chrome-tabs/src/main/res/drawable-mdpi/phone_tab_border.9.png b/chrome-tabs/src/main/res/drawable-mdpi/phone_tab_border.9.png
new file mode 100755
index 0000000..12b73ab
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-mdpi/phone_tab_border.9.png differ
diff --git a/chrome-tabs/src/main/res/drawable-mdpi/tab_switcher_drawable_background.png b/chrome-tabs/src/main/res/drawable-mdpi/tab_switcher_drawable_background.png
new file mode 100755
index 0000000..7a6dcd8
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-mdpi/tab_switcher_drawable_background.png differ
diff --git a/chrome-tabs/src/main/res/drawable-xhdpi/ic_close_tab_18dp.png b/chrome-tabs/src/main/res/drawable-xhdpi/ic_close_tab_18dp.png
new file mode 100755
index 0000000..f7a2aa1
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xhdpi/ic_close_tab_18dp.png differ
diff --git a/chrome-tabs/src/main/res/drawable-xhdpi/phone_tab_background.9.png b/chrome-tabs/src/main/res/drawable-xhdpi/phone_tab_background.9.png
new file mode 100755
index 0000000..20bc872
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xhdpi/phone_tab_background.9.png differ
diff --git a/chrome-tabs/src/main/res/drawable-xhdpi/phone_tab_border.9.png b/chrome-tabs/src/main/res/drawable-xhdpi/phone_tab_border.9.png
new file mode 100755
index 0000000..9190ab9
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xhdpi/phone_tab_border.9.png differ
diff --git a/chrome-tabs/src/main/res/drawable-xhdpi/tab_switcher_drawable_background.png b/chrome-tabs/src/main/res/drawable-xhdpi/tab_switcher_drawable_background.png
new file mode 100755
index 0000000..a345c5e
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xhdpi/tab_switcher_drawable_background.png differ
diff --git a/chrome-tabs/src/main/res/drawable-xxhdpi/ic_close_tab_18dp.png b/chrome-tabs/src/main/res/drawable-xxhdpi/ic_close_tab_18dp.png
new file mode 100755
index 0000000..05a5741
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xxhdpi/ic_close_tab_18dp.png differ
diff --git a/chrome-tabs/src/main/res/drawable-xxhdpi/phone_tab_background.9.png b/chrome-tabs/src/main/res/drawable-xxhdpi/phone_tab_background.9.png
new file mode 100755
index 0000000..33cfd60
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xxhdpi/phone_tab_background.9.png differ
diff --git a/chrome-tabs/src/main/res/drawable-xxhdpi/phone_tab_border.9.png b/chrome-tabs/src/main/res/drawable-xxhdpi/phone_tab_border.9.png
new file mode 100755
index 0000000..bfc0df7
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xxhdpi/phone_tab_border.9.png differ
diff --git a/chrome-tabs/src/main/res/drawable-xxhdpi/tab_switcher_drawable_background.png b/chrome-tabs/src/main/res/drawable-xxhdpi/tab_switcher_drawable_background.png
new file mode 100755
index 0000000..ca78d44
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xxhdpi/tab_switcher_drawable_background.png differ
diff --git a/chrome-tabs/src/main/res/drawable-xxxhdpi/ic_close_tab_18dp.png b/chrome-tabs/src/main/res/drawable-xxxhdpi/ic_close_tab_18dp.png
new file mode 100755
index 0000000..9567930
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xxxhdpi/ic_close_tab_18dp.png differ
diff --git a/chrome-tabs/src/main/res/drawable-xxxhdpi/phone_tab_background.9.png b/chrome-tabs/src/main/res/drawable-xxxhdpi/phone_tab_background.9.png
new file mode 100755
index 0000000..3c94a8b
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xxxhdpi/phone_tab_background.9.png differ
diff --git a/chrome-tabs/src/main/res/drawable-xxxhdpi/phone_tab_border.9.png b/chrome-tabs/src/main/res/drawable-xxxhdpi/phone_tab_border.9.png
new file mode 100755
index 0000000..2ac893b
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xxxhdpi/phone_tab_border.9.png differ
diff --git a/chrome-tabs/src/main/res/drawable-xxxhdpi/tab_switcher_drawable_background.png b/chrome-tabs/src/main/res/drawable-xxxhdpi/tab_switcher_drawable_background.png
new file mode 100755
index 0000000..f435a65
Binary files /dev/null and b/chrome-tabs/src/main/res/drawable-xxxhdpi/tab_switcher_drawable_background.png differ
diff --git a/chrome-tabs/src/main/res/layout-land/phone_tab.xml b/chrome-tabs/src/main/res/layout-land/phone_tab.xml
new file mode 100755
index 0000000..fab0950
--- /dev/null
+++ b/chrome-tabs/src/main/res/layout-land/phone_tab.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/chrome-tabs/src/main/res/layout/phone_tab.xml b/chrome-tabs/src/main/res/layout/phone_tab.xml
new file mode 100755
index 0000000..0b90831
--- /dev/null
+++ b/chrome-tabs/src/main/res/layout/phone_tab.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/chrome-tabs/src/main/res/layout/phone_toolbar.xml b/chrome-tabs/src/main/res/layout/phone_toolbar.xml
new file mode 100755
index 0000000..507b1d7
--- /dev/null
+++ b/chrome-tabs/src/main/res/layout/phone_toolbar.xml
@@ -0,0 +1,25 @@
+
+
+
+
\ No newline at end of file
diff --git a/chrome-tabs/src/main/res/layout/tab_switcher_menu_item.xml b/chrome-tabs/src/main/res/layout/tab_switcher_menu_item.xml
new file mode 100755
index 0000000..c400afc
--- /dev/null
+++ b/chrome-tabs/src/main/res/layout/tab_switcher_menu_item.xml
@@ -0,0 +1,19 @@
+
+
+
+
\ No newline at end of file
diff --git a/chrome-tabs/src/main/res/values-v21/styles.xml b/chrome-tabs/src/main/res/values-v21/styles.xml
new file mode 100755
index 0000000..4a80ff8
--- /dev/null
+++ b/chrome-tabs/src/main/res/values-v21/styles.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/chrome-tabs/src/main/res/values/attrs.xml b/chrome-tabs/src/main/res/values/attrs.xml
new file mode 100755
index 0000000..2d201ad
--- /dev/null
+++ b/chrome-tabs/src/main/res/values/attrs.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/chrome-tabs/src/main/res/values/colors.xml b/chrome-tabs/src/main/res/values/colors.xml
new file mode 100755
index 0000000..0f65732
--- /dev/null
+++ b/chrome-tabs/src/main/res/values/colors.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+ #ff14181c
+ #fff2f2f2
+ #a8000000
+
+
\ No newline at end of file
diff --git a/chrome-tabs/src/main/res/values/dimens.xml b/chrome-tabs/src/main/res/values/dimens.xml
new file mode 100755
index 0000000..5bdafdf
--- /dev/null
+++ b/chrome-tabs/src/main/res/values/dimens.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+ 16dp
+ 4dp
+ 48dp
+ 12dp
+ 8dp
+ 128dp
+ 4dp
+ 48dp
+ 1280dp
+ 1024dp
+ 8dp
+ 24dp
+ 32dp
+ 10sp
+ 7sp
+ - 0.5
+ - 0
+
+
\ No newline at end of file
diff --git a/chrome-tabs/src/main/res/values/ids.xml b/chrome-tabs/src/main/res/values/ids.xml
new file mode 100755
index 0000000..f6013c6
--- /dev/null
+++ b/chrome-tabs/src/main/res/values/ids.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/chrome-tabs/src/main/res/values/integers.xml b/chrome-tabs/src/main/res/values/integers.xml
new file mode 100755
index 0000000..f9b9577
--- /dev/null
+++ b/chrome-tabs/src/main/res/values/integers.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+ 3
+ 2
+ 3
+ @android:integer/config_shortAnimTime
+ @android:integer/config_shortAnimTime
+ @android:integer/config_mediumAnimTime
+ @android:integer/config_shortAnimTime
+ @android:integer/config_longAnimTime
+ @android:integer/config_shortAnimTime
+ @android:integer/config_mediumAnimTime
+ @android:integer/config_shortAnimTime
+ @android:integer/config_shortAnimTime
+ @android:integer/config_shortAnimTime
+ 1200
+
+
\ No newline at end of file
diff --git a/chrome-tabs/src/main/res/values/styles.xml b/chrome-tabs/src/main/res/values/styles.xml
new file mode 100755
index 0000000..f9337b9
--- /dev/null
+++ b/chrome-tabs/src/main/res/values/styles.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..aac7c9b
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,17 @@
+# Project-wide Gradle settings.
+
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx1536m
+
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..13372ae
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..4f7af6d
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Sun Jun 11 16:46:54 CST 2017
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-4.0-milestone-1-all.zip
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..9d82f78
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn ( ) {
+ echo "$*"
+}
+
+die ( ) {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+esac
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
+function splitJvmOpts() {
+ JVM_OPTS=("$@")
+}
+eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
+JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
+
+exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..aec9973
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,90 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windowz variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+if "%@eval[2+2]" == "4" goto 4NT_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+goto execute
+
+:4NT_args
+@rem Get arguments from the 4NT Shell from JP Software
+set CMD_LINE_ARGS=%$
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000..db5559a
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1 @@
+include ':app', ':chrome-tabs'