0) || (y > getHeight() - mGutterSize && dy < 0);
- }
-
- private void enableLayers(boolean enable) {
- final int childCount = getChildCount();
- for (int i = 0; i < childCount; i++) {
- final int layerType = enable ?
- ViewCompat.LAYER_TYPE_HARDWARE : ViewCompat.LAYER_TYPE_NONE;
- ViewCompat.setLayerType(getChildAt(i), layerType, null);
- }
- }
-
- @Override
- public boolean onInterceptTouchEvent(MotionEvent ev) {
- /*
- * This method JUST determines whether we want to intercept the motion.
- * If we return true, onMotionEvent will be called and we do the actual
- * scrolling there.
- */
-
- final int action = ev.getAction() & MotionEventCompat.ACTION_MASK;
-
- // Always take care of the touch gesture being complete.
- if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
- // Release the drag.
- if (DEBUG) Log.v(TAG, "Intercept done!");
- resetTouch();
- return false;
- }
-
- // Nothing more to do here if we have decided whether or not we
- // are dragging.
- if (action != MotionEvent.ACTION_DOWN) {
- if (mIsBeingDragged) {
- if (DEBUG) Log.v(TAG, "Intercept returning true!");
- return true;
- }
- if (mIsUnableToDrag) {
- if (DEBUG) Log.v(TAG, "Intercept returning false!");
- return false;
- }
- }
-
- switch (action) {
- case MotionEvent.ACTION_MOVE: {
- /*
- * mIsBeingDragged == false, otherwise the shortcut would have caught it. Check
- * whether the user has moved far enough from his original down touch.
- */
-
- /*
- * Locally do absolute value. mLastMotionY is set to the y value
- * of the down event.
- */
- final int activePointerId = mActivePointerId;
- if (activePointerId == INVALID_POINTER) {
- // If we don't have a valid id, the touch down wasn't on content.
- break;
- }
-
- final int pointerIndex = MotionEventCompat.findPointerIndex(ev, activePointerId);
- final float y = MotionEventCompat.getY(ev, pointerIndex);
- final float dy = y - mLastMotionY;
- final float yDiff = Math.abs(dy);
- final float x = MotionEventCompat.getX(ev, pointerIndex);
- final float xDiff = Math.abs(x - mInitialMotionX);
- if (DEBUG) Log.v(TAG, "Moved x to " + x + "," + y + " diff=" + xDiff + "," + yDiff);
-
- if (dy != 0 && !isGutterDrag(mLastMotionY, dy) &&
- canScroll(this, false, (int) dy, (int) x, (int) y)) {
- // Nested view has scrollable area under this point. Let it be handled there.
- mLastMotionX = x;
- mLastMotionY = y;
- mIsUnableToDrag = true;
- return false;
- }
- if (yDiff > mTouchSlop && yDiff * 0.5f > xDiff) {
- if (DEBUG) Log.v(TAG, "Starting drag!");
- mIsBeingDragged = true;
- requestParentDisallowInterceptTouchEvent(true);
- setScrollState(SCROLL_STATE_DRAGGING);
- mLastMotionY = dy > 0 ? mInitialMotionY + mTouchSlop :
- mInitialMotionY - mTouchSlop;
- mLastMotionX = x;
- setScrollingCacheEnabled(true);
- } else if (xDiff > mTouchSlop) {
- // The finger has moved enough in the vertical
- // direction to be counted as a drag... abort
- // any attempt to drag horizontally, to work correctly
- // with children that have scrolling containers.
- if (DEBUG) Log.v(TAG, "Starting unable to drag!");
- mIsUnableToDrag = true;
- }
- // Scroll to follow the motion event
- if (mIsBeingDragged && performDrag(y)) {
- ViewCompat.postInvalidateOnAnimation(this);
- }
- break;
- }
-
- case MotionEvent.ACTION_DOWN: {
- /*
- * Remember location of down touch.
- * ACTION_DOWN always refers to pointer index 0.
- */
- mLastMotionX = mInitialMotionX = ev.getX();
- mLastMotionY = mInitialMotionY = ev.getY();
- mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
- mIsUnableToDrag = false;
-
- mScroller.computeScrollOffset();
- if (mScrollState == SCROLL_STATE_SETTLING &&
- Math.abs(mScroller.getFinalY() - mScroller.getCurrY()) > mCloseEnough) {
- // Let the user 'catch' the pager as it animates.
- mScroller.abortAnimation();
- mPopulatePending = false;
- populate();
- mIsBeingDragged = true;
- requestParentDisallowInterceptTouchEvent(true);
- setScrollState(SCROLL_STATE_DRAGGING);
- } else {
- completeScroll(false);
- mIsBeingDragged = false;
- }
-
- if (DEBUG) Log.v(TAG, "Down at " + mLastMotionX + "," + mLastMotionY
- + " mIsBeingDragged=" + mIsBeingDragged
- + "mIsUnableToDrag=" + mIsUnableToDrag);
- break;
- }
-
- case MotionEventCompat.ACTION_POINTER_UP:
- onSecondaryPointerUp(ev);
- break;
- default:
- break;
- }
-
- if (mVelocityTracker == null) {
- mVelocityTracker = VelocityTracker.obtain();
- }
- mVelocityTracker.addMovement(ev);
-
- /*
- * The only time we want to intercept motion events is if we are in the
- * drag mode.
- */
- return mIsBeingDragged;
- }
-
- @Override
- public boolean onTouchEvent(MotionEvent ev) {
- if (mFakeDragging) {
- // A fake drag is in progress already, ignore this real one
- // but still eat the touch events.
- // (It is likely that the user is multi-touching the screen.)
- return true;
- }
-
- if (ev.getAction() == MotionEvent.ACTION_DOWN && ev.getEdgeFlags() != 0) {
- // Don't handle edge touches immediately -- they may actually belong to one of our
- // descendants.
- return false;
- }
-
- if (mAdapter == null || mAdapter.getCount() == 0) {
- // Nothing to present or scroll; nothing to touch.
- return false;
- }
-
- if (mVelocityTracker == null) {
- mVelocityTracker = VelocityTracker.obtain();
- }
- mVelocityTracker.addMovement(ev);
-
- final int action = ev.getAction();
- boolean needsInvalidate = false;
-
- switch (action & MotionEventCompat.ACTION_MASK) {
- case MotionEvent.ACTION_DOWN: {
- mScroller.abortAnimation();
- mPopulatePending = false;
- populate();
-
- // Remember where the motion event started
- mLastMotionX = mInitialMotionX = ev.getX();
- mLastMotionY = mInitialMotionY = ev.getY();
- mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
- break;
- }
- case MotionEvent.ACTION_MOVE:
- if (!mIsBeingDragged) {
- final int pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId);
- if (pointerIndex == -1) {
- // A child has consumed some touch events and put us into an inconsistent state.
- needsInvalidate = resetTouch();
- break;
- }
- final float y = MotionEventCompat.getY(ev, pointerIndex);
- final float yDiff = Math.abs(y - mLastMotionY);
- final float x = MotionEventCompat.getX(ev, pointerIndex);
- final float xDiff = Math.abs(x - mLastMotionX);
- if (DEBUG) Log.v(TAG, "Moved x to " + x + "," + y + " diff=" + xDiff + "," + yDiff);
- if (yDiff > mTouchSlop && yDiff > xDiff) {
- if (DEBUG) Log.v(TAG, "Starting drag!");
- mIsBeingDragged = true;
- requestParentDisallowInterceptTouchEvent(true);
- mLastMotionY = y - mInitialMotionY > 0 ? mInitialMotionY + mTouchSlop :
- mInitialMotionY - mTouchSlop;
- mLastMotionX = x;
- setScrollState(SCROLL_STATE_DRAGGING);
- setScrollingCacheEnabled(true);
-
- // Disallow Parent Intercept, just in case
- ViewParent parent = getParent();
- if (parent != null) {
- parent.requestDisallowInterceptTouchEvent(true);
- }
- }
- }
- // Not else! Note that mIsBeingDragged can be set above.
- if (mIsBeingDragged) {
- // Scroll to follow the motion event
- final int activePointerIndex = MotionEventCompat.findPointerIndex(
- ev, mActivePointerId);
- final float y = MotionEventCompat.getY(ev, activePointerIndex);
- needsInvalidate |= performDrag(y);
- }
- break;
- case MotionEvent.ACTION_UP:
- if (mIsBeingDragged) {
- final VelocityTracker velocityTracker = mVelocityTracker;
- velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
- int initialVelocity = (int) VelocityTrackerCompat.getYVelocity(
- velocityTracker, mActivePointerId);
- mPopulatePending = true;
- final int height = getClientHeight();
- final int scrollY = getScrollY();
- final ItemInfo ii = infoForCurrentScrollPosition();
- final int currentPage = ii.position;
- final float pageOffset = (((float) scrollY / height) - ii.offset) / ii.heightFactor;
- final int activePointerIndex =
- MotionEventCompat.findPointerIndex(ev, mActivePointerId);
- final float y = MotionEventCompat.getY(ev, activePointerIndex);
- final int totalDelta = (int) (y - mInitialMotionY);
- int nextPage = determineTargetPage(currentPage, pageOffset, initialVelocity,
- totalDelta);
- setCurrentItemInternal(nextPage, true, true, initialVelocity);
-
- needsInvalidate = resetTouch();
- }
- break;
- case MotionEvent.ACTION_CANCEL:
- if (mIsBeingDragged) {
- scrollToItem(mCurItem, true, 0, false);
- needsInvalidate = resetTouch();
- }
- break;
- case MotionEventCompat.ACTION_POINTER_DOWN: {
- final int index = MotionEventCompat.getActionIndex(ev);
- final float y = MotionEventCompat.getY(ev, index);
- mLastMotionY = y;
- mActivePointerId = MotionEventCompat.getPointerId(ev, index);
- break;
- }
- case MotionEventCompat.ACTION_POINTER_UP:
- onSecondaryPointerUp(ev);
- mLastMotionY = MotionEventCompat.getY(ev,
- MotionEventCompat.findPointerIndex(ev, mActivePointerId));
- break;
- default:
- break;
- }
- if (needsInvalidate) {
- ViewCompat.postInvalidateOnAnimation(this);
- }
- return true;
- }
-
- private boolean resetTouch() {
- boolean needsInvalidate;
- mActivePointerId = INVALID_POINTER;
- endDrag();
- needsInvalidate = mTopEdge.onRelease() | mBottomEdge.onRelease();
- return needsInvalidate;
- }
-
- private void requestParentDisallowInterceptTouchEvent(boolean disallowIntercept) {
- final ViewParent parent = getParent();
- if (parent != null) {
- parent.requestDisallowInterceptTouchEvent(disallowIntercept);
- }
- }
-
- private boolean performDrag(float y) {
- boolean needsInvalidate = false;
-
- final float deltaY = mLastMotionY - y;
- mLastMotionY = y;
-
- float oldScrollY = getScrollY();
- float scrollY = oldScrollY + deltaY;
- final int height = getClientHeight();
-
- float topBound = height * mFirstOffset;
- float bottomBound = height * mLastOffset;
- boolean topAbsolute = true;
- boolean bottomAbsolute = true;
-
- final ItemInfo firstItem = mItems.get(0);
- final ItemInfo lastItem = mItems.get(mItems.size() - 1);
- if (firstItem.position != 0) {
- topAbsolute = false;
- topBound = firstItem.offset * height;
- }
- if (lastItem.position != mAdapter.getCount() - 1) {
- bottomAbsolute = false;
- bottomBound = lastItem.offset * height;
- }
-
- if (scrollY < topBound) {
- if (topAbsolute) {
- float over = topBound - scrollY;
- needsInvalidate = mTopEdge.onPull(Math.abs(over) / height);
- }
- scrollY = topBound;
- } else if (scrollY > bottomBound) {
- if (bottomAbsolute) {
- float over = scrollY - bottomBound;
- needsInvalidate = mBottomEdge.onPull(Math.abs(over) / height);
- }
- scrollY = bottomBound;
- }
- // Don't lose the rounded component
- mLastMotionY += scrollY - (int) scrollY;
- scrollTo(getScrollX(), (int) scrollY);
- pageScrolled((int) scrollY);
-
- return needsInvalidate;
- }
-
- /**
- * @return Info about the page at the current scroll position.
- * This can be synthetic for a missing middle page; the 'object' field can be null.
- */
- private ItemInfo infoForCurrentScrollPosition() {
- final int height = getClientHeight();
- final float scrollOffset = height > 0 ? (float) getScrollY() / height : 0;
- final float marginOffset = height > 0 ? (float) mPageMargin / height : 0;
- int lastPos = -1;
- float lastOffset = 0.f;
- float lastHeight = 0.f;
- boolean first = true;
-
- ItemInfo lastItem = null;
- for (int i = 0; i < mItems.size(); i++) {
- ItemInfo ii = mItems.get(i);
- float offset;
- if (!first && ii.position != lastPos + 1) {
- // Create a synthetic item for a missing page.
- ii = mTempItem;
- ii.offset = lastOffset + lastHeight + marginOffset;
- ii.position = lastPos + 1;
- ii.heightFactor = mAdapter.getPageWidth(ii.position);
- i--;
- }
- offset = ii.offset;
-
- final float topBound = offset;
- final float bottomBound = offset + ii.heightFactor + marginOffset;
- if (first || scrollOffset >= topBound) {
- if (scrollOffset < bottomBound || i == mItems.size() - 1) {
- return ii;
- }
- } else {
- return lastItem;
- }
- first = false;
- lastPos = ii.position;
- lastOffset = offset;
- lastHeight = ii.heightFactor;
- lastItem = ii;
- }
-
- return lastItem;
- }
-
- private int determineTargetPage(int currentPage, float pageOffset, int velocity, int deltaY) {
- int targetPage;
- if (Math.abs(deltaY) > mFlingDistance && Math.abs(velocity) > mMinimumVelocity) {
- targetPage = velocity > 0 ? currentPage : currentPage + 1;
- } else {
- final float truncator = currentPage >= mCurItem ? 0.4f : 0.6f;
- targetPage = (int) (currentPage + pageOffset + truncator);
- }
-
- if (!mItems.isEmpty()) {
- final ItemInfo firstItem = mItems.get(0);
- final ItemInfo lastItem = mItems.get(mItems.size() - 1);
-
- // Only let the user target pages we have items for
- targetPage = Math.max(firstItem.position, Math.min(targetPage, lastItem.position));
- }
-
- return targetPage;
- }
-
- @Override
- public void draw(Canvas canvas) {
- super.draw(canvas);
- boolean needsInvalidate = false;
-
- final int overScrollMode = ViewCompat.getOverScrollMode(this);
- if (overScrollMode == ViewCompat.OVER_SCROLL_ALWAYS ||
- (overScrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS &&
- mAdapter != null && mAdapter.getCount() > 1)) {
- if (!mTopEdge.isFinished()) {
- final int restoreCount = canvas.save();
- final int height = getHeight();
- final int width = getWidth() - getPaddingLeft() - getPaddingRight();
-
- canvas.translate(getPaddingLeft(), mFirstOffset * height);
- mTopEdge.setSize(width, height);
- needsInvalidate |= mTopEdge.draw(canvas);
- canvas.restoreToCount(restoreCount);
- }
- if (!mBottomEdge.isFinished()) {
- final int restoreCount = canvas.save();
- final int height = getHeight();
- final int width = getWidth() - getPaddingLeft() - getPaddingRight();
-
- canvas.rotate(180);
- canvas.translate(-width - getPaddingLeft(), -(mLastOffset + 1) * height);
- mBottomEdge.setSize(width, height);
- needsInvalidate |= mBottomEdge.draw(canvas);
- canvas.restoreToCount(restoreCount);
- }
- } else {
- mTopEdge.finish();
- mBottomEdge.finish();
- }
-
- if (needsInvalidate) {
- // Keep animating
- ViewCompat.postInvalidateOnAnimation(this);
- }
- }
-
- @Override
- protected void onDraw(Canvas canvas) {
- super.onDraw(canvas);
-
- // Draw the margin drawable between pages if needed.
- if (mPageMargin > 0 && mMarginDrawable != null && !mItems.isEmpty() && mAdapter != null) {
- final int scrollY = getScrollY();
- final int height = getHeight();
-
- final float marginOffset = (float) mPageMargin / height;
- int itemIndex = 0;
- ItemInfo ii = mItems.get(0);
- float offset = ii.offset;
- final int itemCount = mItems.size();
- final int firstPos = ii.position;
- final int lastPos = mItems.get(itemCount - 1).position;
- for (int pos = firstPos; pos < lastPos; pos++) {
- while (pos > ii.position && itemIndex < itemCount) {
- ii = mItems.get(++itemIndex);
- }
-
- float drawAt;
- if (pos == ii.position) {
- drawAt = (ii.offset + ii.heightFactor) * height;
- offset = ii.offset + ii.heightFactor + marginOffset;
- } else {
- float heightFactor = mAdapter.getPageWidth(pos);
- drawAt = (offset + heightFactor) * height;
- offset += heightFactor + marginOffset;
- }
-
- if (drawAt + mPageMargin > scrollY) {
- mMarginDrawable.setBounds(mLeftPageBounds, (int) drawAt,
- mRightPageBounds, (int) (drawAt + mPageMargin + 0.5f));
- mMarginDrawable.draw(canvas);
- }
-
- if (drawAt > scrollY + height) {
- break; // No more visible, no sense in continuing
- }
- }
- }
- }
-
- /**
- * Start a fake drag of the pager.
- *
- * A fake drag can be useful if you want to synchronize the motion of the ViewPager
- * with the touch scrolling of another view, while still letting the ViewPager
- * control the snapping motion and fling behavior. (e.g. parallax-scrolling tabs.)
- * Call {@link #fakeDragBy(float)} to simulate the actual drag motion. Call
- * {@link #endFakeDrag()} to complete the fake drag and fling as necessary.
- *
- *
During a fake drag the ViewPager will ignore all touch events. If a real drag
- * is already in progress, this method will return false.
- *
- * @return true if the fake drag began successfully, false if it could not be started.
- *
- * @see #fakeDragBy(float)
- * @see #endFakeDrag()
- */
- public boolean beginFakeDrag() {
- if (mIsBeingDragged) {
- return false;
- }
- mFakeDragging = true;
- setScrollState(SCROLL_STATE_DRAGGING);
- mInitialMotionY = mLastMotionY = 0;
- if (mVelocityTracker == null) {
- mVelocityTracker = VelocityTracker.obtain();
- } else {
- mVelocityTracker.clear();
- }
- final long time = SystemClock.uptimeMillis();
- final MotionEvent ev = MotionEvent.obtain(time, time, MotionEvent.ACTION_DOWN, 0, 0, 0);
- mVelocityTracker.addMovement(ev);
- ev.recycle();
- mFakeDragBeginTime = time;
- return true;
- }
-
- /**
- * End a fake drag of the pager.
- *
- * @see #beginFakeDrag()
- * @see #fakeDragBy(float)
- */
- public void endFakeDrag() {
- if (!mFakeDragging) {
- throw new IllegalStateException("No fake drag in progress. Call beginFakeDrag first.");
- }
-
- final VelocityTracker velocityTracker = mVelocityTracker;
- velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
- int initialVelocity = (int) VelocityTrackerCompat.getYVelocity(
- velocityTracker, mActivePointerId);
- mPopulatePending = true;
- final int height = getClientHeight();
- final int scrollY = getScrollY();
- final ItemInfo ii = infoForCurrentScrollPosition();
- final int currentPage = ii.position;
- final float pageOffset = (((float) scrollY / height) - ii.offset) / ii.heightFactor;
- final int totalDelta = (int) (mLastMotionY - mInitialMotionY);
- int nextPage = determineTargetPage(currentPage, pageOffset, initialVelocity,
- totalDelta);
- setCurrentItemInternal(nextPage, true, true, initialVelocity);
- endDrag();
-
- mFakeDragging = false;
- }
-
- /**
- * Fake drag by an offset in pixels. You must have called {@link #beginFakeDrag()} first.
- *
- * @param yOffset Offset in pixels to drag by.
- * @see #beginFakeDrag()
- * @see #endFakeDrag()
- */
- public void fakeDragBy(float yOffset) {
- if (!mFakeDragging) {
- throw new IllegalStateException("No fake drag in progress. Call beginFakeDrag first.");
- }
-
- mLastMotionY += yOffset;
-
- float oldScrollY = getScrollY();
- float scrollY = oldScrollY - yOffset;
- final int height = getClientHeight();
-
- float topBound = height * mFirstOffset;
- float bottomBound = height * mLastOffset;
-
- final ItemInfo firstItem = mItems.get(0);
- final ItemInfo lastItem = mItems.get(mItems.size() - 1);
- if (firstItem.position != 0) {
- topBound = firstItem.offset * height;
- }
- if (lastItem.position != mAdapter.getCount() - 1) {
- bottomBound = lastItem.offset * height;
- }
-
- if (scrollY < topBound) {
- scrollY = topBound;
- } else if (scrollY > bottomBound) {
- scrollY = bottomBound;
- }
- // Don't lose the rounded component
- mLastMotionY += scrollY - (int) scrollY;
- scrollTo(getScrollX(), (int) scrollY);
- pageScrolled((int) scrollY);
-
- // Synthesize an event for the VelocityTracker.
- final long time = SystemClock.uptimeMillis();
- final MotionEvent ev = MotionEvent.obtain(mFakeDragBeginTime, time, MotionEvent.ACTION_MOVE,
- 0, mLastMotionY, 0);
- mVelocityTracker.addMovement(ev);
- ev.recycle();
- }
-
- /**
- * Returns true if a fake drag is in progress.
- *
- * @return true if currently in a fake drag, false otherwise.
- *
- * @see #beginFakeDrag()
- * @see #fakeDragBy(float)
- * @see #endFakeDrag()
- */
- public boolean isFakeDragging() {
- return mFakeDragging;
- }
-
- private void onSecondaryPointerUp(MotionEvent ev) {
- final int pointerIndex = MotionEventCompat.getActionIndex(ev);
- final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex);
- if (pointerId == mActivePointerId) {
- // This was our active pointer going up. Choose a new
- // active pointer and adjust accordingly.
- final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
- mLastMotionY = MotionEventCompat.getY(ev, newPointerIndex);
- mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex);
- if (mVelocityTracker != null) {
- mVelocityTracker.clear();
- }
- }
- }
-
- private void endDrag() {
- mIsBeingDragged = false;
- mIsUnableToDrag = false;
-
- if (mVelocityTracker != null) {
- mVelocityTracker.recycle();
- mVelocityTracker = null;
- }
- }
-
- private void setScrollingCacheEnabled(boolean enabled) {
- if (mScrollingCacheEnabled != enabled) {
- mScrollingCacheEnabled = enabled;
- if (USE_CACHE) {
- final int size = getChildCount();
- for (int i = 0; i < size; ++i) {
- final View child = getChildAt(i);
- if (child.getVisibility() != GONE) {
- child.setDrawingCacheEnabled(enabled);
- }
- }
- }
- }
- }
-
- public boolean internalCanScrollVertically(int direction) {
- if (mAdapter == null) {
- return false;
- }
-
- final int height = getClientHeight();
- final int scrollY = getScrollY();
- if (direction < 0) {
- return (scrollY > (int) (height * mFirstOffset));
- } else if (direction > 0) {
- return (scrollY < (int) (height * mLastOffset));
- } else {
- return false;
- }
- }
-
- /**
- * Tests scrollability within child views of v given a delta of dx.
- *
- * @param v View to test for horizontal scrollability
- * @param checkV Whether the view v passed should itself be checked for scrollability (true),
- * or just its children (false).
- * @param dy Delta scrolled in pixels
- * @param x X coordinate of the active touch point
- * @param y Y coordinate of the active touch point
- * @return true if child views of v can be scrolled by delta of dx.
- */
- protected boolean canScroll(View v, boolean checkV, int dy, int x, int y) {
- if (v instanceof ViewGroup) {
- final ViewGroup group = (ViewGroup) v;
- final int scrollX = v.getScrollX();
- final int scrollY = v.getScrollY();
- final int count = group.getChildCount();
- // Count backwards - let topmost views consume scroll distance first.
- for (int i = count - 1; i >= 0; i--) {
- // TODO: Add versioned support here for transformed views.
- // This will not work for transformed views in Honeycomb+
- final View child = group.getChildAt(i);
- if (y + scrollY >= child.getTop() && y + scrollY < child.getBottom() &&
- x + scrollX >= child.getLeft() && x + scrollX < child.getRight() &&
- canScroll(child, true, dy, x + scrollX - child.getLeft(),
- y + scrollY - child.getTop())) {
- return true;
- }
- }
- }
-
- return checkV && ViewCompat.canScrollVertically(v, -dy);
- }
-
- @Override
- public boolean dispatchKeyEvent(KeyEvent event) {
- // Let the focused view and/or our descendants get the key first
- return super.dispatchKeyEvent(event) || executeKeyEvent(event);
- }
-
- /**
- * You can call this function yourself to have the scroll view perform
- * scrolling from a key event, just as if the event had been dispatched to
- * it by the view hierarchy.
- *
- * @param event The key event to execute.
- * @return Return true if the event was handled, else false.
- */
- public boolean executeKeyEvent(KeyEvent event) {
- boolean handled = false;
- if (event.getAction() == KeyEvent.ACTION_DOWN) {
- switch (event.getKeyCode()) {
- case KeyEvent.KEYCODE_DPAD_LEFT:
- handled = arrowScroll(FOCUS_LEFT);
- break;
- case KeyEvent.KEYCODE_DPAD_RIGHT:
- handled = arrowScroll(FOCUS_RIGHT);
- break;
- case KeyEvent.KEYCODE_TAB:
- if (event.hasNoModifiers()) {
- handled = arrowScroll(FOCUS_FORWARD);
- } else if (event.hasModifiers(KeyEvent.META_SHIFT_ON)) {
- handled = arrowScroll(FOCUS_BACKWARD);
- }
- break;
- default:
- break;
- }
- }
- return handled;
- }
-
- public boolean arrowScroll(int direction) {
- View currentFocused = findFocus();
- if (currentFocused == this) {
- currentFocused = null;
- } else if (currentFocused != null) {
- boolean isChild = false;
- for (ViewParent parent = currentFocused.getParent(); parent instanceof ViewGroup;
- parent = parent.getParent()) {
- if (parent == this) {
- isChild = true;
- break;
- }
- }
- if (!isChild) {
- // This would cause the focus search down below to fail in fun ways.
- final StringBuilder sb = new StringBuilder();
- sb.append(currentFocused.getClass().getSimpleName());
- for (ViewParent parent = currentFocused.getParent(); parent instanceof ViewGroup;
- parent = parent.getParent()) {
- sb.append(" => ").append(parent.getClass().getSimpleName());
- }
- Log.e(TAG, "arrowScroll tried to find focus based on non-child " +
- "current focused view " + sb.toString());
- currentFocused = null;
- }
- }
-
- boolean handled = false;
-
- View nextFocused = FocusFinder.getInstance().findNextFocus(this, currentFocused,
- direction);
- if (nextFocused != null && nextFocused != currentFocused) {
- if (direction == View.FOCUS_UP) {
- // If there is nothing to the left, or this is causing us to
- // jump to the right, then what we really want to do is page left.
- final int nextTop = getChildRectInPagerCoordinates(mTempRect, nextFocused).top;
- final int currTop = getChildRectInPagerCoordinates(mTempRect, currentFocused).top;
- if (currentFocused != null && nextTop >= currTop) {
- handled = pageUp();
- } else {
- handled = nextFocused.requestFocus();
- }
- } else if (direction == View.FOCUS_DOWN) {
- // If there is nothing to the right, or this is causing us to
- // jump to the left, then what we really want to do is page right.
- final int nextDown = getChildRectInPagerCoordinates(mTempRect, nextFocused).bottom;
- final int currDown = getChildRectInPagerCoordinates(mTempRect, currentFocused).bottom;
- if (currentFocused != null && nextDown <= currDown) {
- handled = pageDown();
- } else {
- handled = nextFocused.requestFocus();
- }
- }
- } else if (direction == FOCUS_UP || direction == FOCUS_BACKWARD) {
- // Trying to move left and nothing there; try to page.
- handled = pageUp();
- } else if (direction == FOCUS_DOWN || direction == FOCUS_FORWARD) {
- // Trying to move right and nothing there; try to page.
- handled = pageDown();
- }
- if (handled) {
- playSoundEffect(SoundEffectConstants.getContantForFocusDirection(direction));
- }
- return handled;
- }
-
- private Rect getChildRectInPagerCoordinates(Rect outRect, View child) {
- if (outRect == null) {
- outRect = new Rect();
- }
- if (child == null) {
- outRect.set(0, 0, 0, 0);
- return outRect;
- }
- outRect.left = child.getLeft();
- outRect.right = child.getRight();
- outRect.top = child.getTop();
- outRect.bottom = child.getBottom();
-
- ViewParent parent = child.getParent();
- while (parent instanceof ViewGroup && parent != this) {
- final ViewGroup group = (ViewGroup) parent;
- outRect.left += group.getLeft();
- outRect.right += group.getRight();
- outRect.top += group.getTop();
- outRect.bottom += group.getBottom();
-
- parent = group.getParent();
- }
- return outRect;
- }
-
- boolean pageUp() {
- if (mCurItem > 0) {
- setCurrentItem(mCurItem-1, true);
- return true;
- }
- return false;
- }
-
- boolean pageDown() {
- if (mAdapter != null && mCurItem < (mAdapter.getCount()-1)) {
- setCurrentItem(mCurItem+1, true);
- return true;
- }
- return false;
- }
-
- /**
- * We only want the current page that is being shown to be focusable.
- */
- @Override
- public void addFocusables(ArrayList views, int direction, int focusableMode) {
- final int focusableCount = views.size();
-
- final int descendantFocusability = getDescendantFocusability();
-
- if (descendantFocusability != FOCUS_BLOCK_DESCENDANTS) {
- for (int i = 0; i < getChildCount(); i++) {
- final View child = getChildAt(i);
- if (child.getVisibility() == VISIBLE) {
- ItemInfo ii = infoForChild(child);
- if (ii != null && ii.position == mCurItem) {
- child.addFocusables(views, direction, focusableMode);
- }
- }
- }
- }
-
- // we add ourselves (if focusable) in all cases except for when we are
- // FOCUS_AFTER_DESCENDANTS and there are some descendants focusable. this is
- // to avoid the focus search finding layouts when a more precise search
- // among the focusable children would be more interesting.
- if (
- descendantFocusability != FOCUS_AFTER_DESCENDANTS ||
- // No focusable descendants
- (focusableCount == views.size())) {
- // Note that we can't call the superclass here, because it will
- // add all views in. So we need to do the same thing View does.
- if (!isFocusable()) {
- return;
- }
- if ((focusableMode & FOCUSABLES_TOUCH_MODE) == FOCUSABLES_TOUCH_MODE &&
- isInTouchMode() && !isFocusableInTouchMode()) {
- return;
- }
- if (views != null) {
- views.add(this);
- }
- }
- }
-
- /**
- * We only want the current page that is being shown to be touchable.
- */
- @Override
- public void addTouchables(ArrayList views) {
- // Note that we don't call super.addTouchables(), which means that
- // we don't call View.addTouchables(). This is okay because a ViewPager
- // is itself not touchable.
- for (int i = 0; i < getChildCount(); i++) {
- final View child = getChildAt(i);
- if (child.getVisibility() == VISIBLE) {
- ItemInfo ii = infoForChild(child);
- if (ii != null && ii.position == mCurItem) {
- child.addTouchables(views);
- }
- }
- }
- }
-
- /**
- * We only want the current page that is being shown to be focusable.
- */
- @Override
- protected boolean onRequestFocusInDescendants(int direction,
- Rect previouslyFocusedRect) {
- int index;
- int increment;
- int end;
- int count = getChildCount();
- if ((direction & FOCUS_FORWARD) != 0) {
- index = 0;
- increment = 1;
- end = count;
- } else {
- index = count - 1;
- increment = -1;
- end = -1;
- }
- for (int i = index; i != end; i += increment) {
- View child = getChildAt(i);
- if (child.getVisibility() == VISIBLE) {
- ItemInfo ii = infoForChild(child);
- if (ii != null && ii.position == mCurItem && child.requestFocus(direction, previouslyFocusedRect)) {
- return true;
- }
- }
- }
- return false;
- }
-
- @Override
- public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
- // Dispatch scroll events from this ViewPager.
- if (event.getEventType() == AccessibilityEventCompat.TYPE_VIEW_SCROLLED) {
- return super.dispatchPopulateAccessibilityEvent(event);
- }
-
- // Dispatch all other accessibility events from the current page.
- final int childCount = getChildCount();
- for (int i = 0; i < childCount; i++) {
- final View child = getChildAt(i);
- if (child.getVisibility() == VISIBLE) {
- final ItemInfo ii = infoForChild(child);
- if (ii != null && ii.position == mCurItem &&
- child.dispatchPopulateAccessibilityEvent(event)) {
- return true;
- }
- }
- }
-
- return false;
- }
-
- @Override
- protected ViewGroup.LayoutParams generateDefaultLayoutParams() {
- return new LayoutParams();
- }
-
- @Override
- protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
- return generateDefaultLayoutParams();
- }
-
- @Override
- protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
- return p instanceof LayoutParams && super.checkLayoutParams(p);
- }
-
- @Override
- public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) {
- return new LayoutParams(getContext(), attrs);
- }
-
- class MyAccessibilityDelegate extends AccessibilityDelegateCompat {
-
- @Override
- public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) {
- super.onInitializeAccessibilityEvent(host, event);
- event.setClassName(VerticalViewPagerImpl.class.getName());
- final AccessibilityRecordCompat recordCompat = AccessibilityRecordCompat.obtain();
- recordCompat.setScrollable(canScroll());
- if (event.getEventType() == AccessibilityEventCompat.TYPE_VIEW_SCROLLED
- && mAdapter != null) {
- recordCompat.setItemCount(mAdapter.getCount());
- recordCompat.setFromIndex(mCurItem);
- recordCompat.setToIndex(mCurItem);
- }
- }
-
- @Override
- public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) {
- super.onInitializeAccessibilityNodeInfo(host, info);
- info.setClassName(VerticalViewPagerImpl.class.getName());
- info.setScrollable(canScroll());
- if (internalCanScrollVertically(1)) {
- info.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD);
- }
- if (internalCanScrollVertically(-1)) {
- info.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD);
- }
- }
-
- @Override
- public boolean performAccessibilityAction(View host, int action, Bundle args) {
- if (super.performAccessibilityAction(host, action, args)) {
- return true;
- }
- switch (action) {
- case AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD: {
- if (internalCanScrollVertically(1)) {
- setCurrentItem(mCurItem + 1);
- return true;
- }
- } return false;
- case AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD: {
- if (internalCanScrollVertically(-1)) {
- setCurrentItem(mCurItem - 1);
- return true;
- }
- } return false;
- default:
- break;
- }
- return false;
- }
-
- private boolean canScroll() {
- return (mAdapter != null) && (mAdapter.getCount() > 1);
- }
- }
-
- private class PagerObserver extends DataSetObserver {
- @Override
- public void onChanged() {
- dataSetChanged();
- }
- @Override
- public void onInvalidated() {
- dataSetChanged();
- }
- }
-
- /**
- * Layout parameters that should be supplied for views added to a
- * ViewPager.
- */
- public static class LayoutParams extends ViewGroup.LayoutParams {
- /**
- * true if this view is a decoration on the pager itself and not
- * a view supplied by the adapter.
- */
- public boolean isDecor;
-
- /**
- * Gravity setting for use on decor views only:
- * Where to position the view page within the overall ViewPager
- * container; constants are defined in {@link android.view.Gravity}.
- */
- public int gravity;
-
- /**
- * Width as a 0-1 multiplier of the measured pager width
- */
- private float heightFactor = 0.f;
-
- /**
- * true if this view was added during layout and needs to be measured
- * before being positioned.
- */
- private boolean needsMeasure;
-
- /**
- * Adapter position this view is for if !isDecor
- */
- private int position;
-
- /**
- * Current child index within the ViewPager that this view occupies
- */
- private int childIndex;
-
- public LayoutParams() {
- super(FILL_PARENT, FILL_PARENT);
- }
-
- public LayoutParams(Context context, AttributeSet attrs) {
- super(context, attrs);
-
- final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS);
- gravity = a.getInteger(0, Gravity.TOP);
- a.recycle();
- }
- }
-
- static class ViewPositionComparator implements Comparator {
- @Override
- public int compare(View lhs, View rhs) {
- final LayoutParams llp = (LayoutParams) lhs.getLayoutParams();
- final LayoutParams rlp = (LayoutParams) rhs.getLayoutParams();
- if (llp.isDecor != rlp.isDecor) {
- return llp.isDecor ? 1 : -1;
- }
- return llp.position - rlp.position;
- }
- }
-}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt
index 6871e6bab..e215646c1 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt
@@ -1,78 +1,172 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
+import android.support.v7.util.DiffUtil
import android.support.v7.widget.RecyclerView
-import android.view.View
import android.view.ViewGroup
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.source.model.Page
-import eu.kanade.tachiyomi.util.inflate
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
+import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
+import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
/**
- * Adapter of pages for a RecyclerView.
- *
- * @param fragment the fragment containing this adapter.
+ * RecyclerView Adapter used by this [viewer] to where [ViewerChapters] updates are posted.
*/
-class WebtoonAdapter(val fragment: WebtoonReader) : RecyclerView.Adapter() {
+class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter() {
/**
- * Pages stored in the adapter.
+ * List of currently set items.
*/
- var pages: List? = null
+ var items: List = emptyList()
+ private set
/**
- * Touch listener for images in holders.
+ * Updates this adapter with the given [chapters]. It handles setting a few pages of the
+ * next/previous chapter to allow seamless transitions.
*/
- val touchListener = View.OnTouchListener { _, ev -> fragment.imageGestureDetector.onTouchEvent(ev) }
+ fun setChapters(chapters: ViewerChapters) {
+ val newItems = mutableListOf()
+
+ // Add previous chapter pages and transition.
+ if (chapters.prevChapter != null) {
+ // We only need to add the last few pages of the previous chapter, because it'll be
+ // selected as the current chapter when one of those pages is selected.
+ val prevPages = chapters.prevChapter.pages
+ if (prevPages != null) {
+ newItems.addAll(prevPages.takeLast(2))
+ }
+ }
+ newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
+
+ // Add current chapter.
+ val currPages = chapters.currChapter.pages
+ if (currPages != null) {
+ newItems.addAll(currPages)
+ }
+
+ // Add next chapter transition and pages.
+ newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
+ if (chapters.nextChapter != null) {
+ // Add at most two pages, because this chapter will be selected before the user can
+ // swap more pages.
+ val nextPages = chapters.nextChapter.pages
+ if (nextPages != null) {
+ newItems.addAll(nextPages.take(2))
+ }
+ }
+
+ val result = DiffUtil.calculateDiff(Callback(items, newItems))
+ items = newItems
+ result.dispatchUpdatesTo(this)
+ }
/**
- * Returns the number of pages.
- *
- * @return the number of pages or 0 if the list is null.
+ * Returns the amount of items of the adapter.
*/
override fun getItemCount(): Int {
- return pages?.size ?: 0
+ return items.size
}
/**
- * Returns a page given the position.
- *
- * @param position the position of the page.
- * @return the page.
+ * Returns the view type for the item at the given [position].
*/
- fun getItem(position: Int): Page {
- return pages!![position]
+ override fun getItemViewType(position: Int): Int {
+ val item = items[position]
+ return when (item) {
+ is ReaderPage -> PAGE_VIEW
+ is ChapterTransition -> TRANSITION_VIEW
+ else -> error("Unknown view type for ${item.javaClass}")
+ }
}
/**
- * Creates a new view holder.
- *
- * @param parent the parent view.
- * @param viewType the type of the holder.
- * @return a new view holder for a manga.
+ * Creates a new view holder for an item with the given [viewType].
*/
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WebtoonHolder {
- val v = parent.inflate(R.layout.reader_webtoon_item)
- return WebtoonHolder(v, this)
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ return when (viewType) {
+ PAGE_VIEW -> {
+ val view = FrameLayout(parent.context)
+ WebtoonPageHolder(view, viewer)
+ }
+ TRANSITION_VIEW -> {
+ val view = LinearLayout(parent.context)
+ WebtoonTransitionHolder(view, viewer)
+ }
+ else -> error("Unknown view type")
+ }
}
/**
- * Binds a holder with a new position.
- *
- * @param holder the holder to bind.
- * @param position the position to bind.
+ * Binds an existing view [holder] with the item at the given [position].
*/
- override fun onBindViewHolder(holder: WebtoonHolder, position: Int) {
- val page = getItem(position)
- holder.onSetValues(page)
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+ val item = items[position]
+ when (holder) {
+ is WebtoonPageHolder -> holder.bind(item as ReaderPage)
+ is WebtoonTransitionHolder -> holder.bind(item as ChapterTransition)
+ }
}
/**
- * Recycles the view holder.
- *
- * @param holder the holder to recycle.
+ * Recycles an existing view [holder] before adding it to the view pool.
*/
- override fun onViewRecycled(holder: WebtoonHolder) {
- holder.onRecycle()
+ override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
+ when (holder) {
+ is WebtoonPageHolder -> holder.recycle()
+ is WebtoonTransitionHolder -> holder.recycle()
+ }
+ }
+
+ /**
+ * Diff util callback used to dispatch delta updates instead of full dataset changes.
+ */
+ private class Callback(
+ private val oldItems: List,
+ private val newItems: List
+ ) : DiffUtil.Callback() {
+
+ /**
+ * Returns true if these two items are the same.
+ */
+ override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
+ val oldItem = oldItems[oldItemPosition]
+ val newItem = newItems[newItemPosition]
+
+ return oldItem == newItem
+ }
+
+ /**
+ * Returns true if the contents of the items are the same.
+ */
+ override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
+ return true
+ }
+
+ /**
+ * Returns the size of the old list.
+ */
+ override fun getOldListSize(): Int {
+ return oldItems.size
+ }
+
+ /**
+ * Returns the size of the new list.
+ */
+ override fun getNewListSize(): Int {
+ return newItems.size
+ }
+ }
+
+ private companion object {
+ /**
+ * View holder type of a chapter page view.
+ */
+ const val PAGE_VIEW = 0
+
+ /**
+ * View holder type of a chapter transition view.
+ */
+ const val TRANSITION_VIEW = 1
}
}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonBaseHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonBaseHolder.kt
new file mode 100644
index 000000000..293127cb3
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonBaseHolder.kt
@@ -0,0 +1,46 @@
+package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
+
+import android.content.Context
+import android.view.View
+import android.view.ViewGroup.LayoutParams
+import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder
+import rx.Subscription
+
+abstract class WebtoonBaseHolder(
+ view: View,
+ protected val viewer: WebtoonViewer
+) : BaseViewHolder(view) {
+
+ /**
+ * Context getter because it's used often.
+ */
+ val context: Context get() = itemView.context
+
+ /**
+ * Called when the view is recycled and being added to the view pool.
+ */
+ open fun recycle() {}
+
+ /**
+ * Adds a subscription to a list of subscriptions that will automatically unsubscribe when the
+ * activity or the reader is destroyed.
+ */
+ protected fun addSubscription(subscription: Subscription?) {
+ viewer.subscriptions.add(subscription)
+ }
+
+ /**
+ * Removes a subscription from the list of subscriptions.
+ */
+ protected fun removeSubscription(subscription: Subscription?) {
+ subscription?.let { viewer.subscriptions.remove(it) }
+ }
+
+ /**
+ * Extension method to set layout params to wrap content on this view.
+ */
+ protected fun View.wrapContent() {
+ layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
+ }
+
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt
new file mode 100644
index 000000000..f575e4ac4
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonConfig.kt
@@ -0,0 +1,68 @@
+package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
+
+import com.f2prateek.rx.preferences.Preference
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.util.addTo
+import rx.subscriptions.CompositeSubscription
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+
+/**
+ * Configuration used by webtoon viewers.
+ */
+class WebtoonConfig(preferences: PreferencesHelper = Injekt.get()) {
+
+ private val subscriptions = CompositeSubscription()
+
+ var imagePropertyChangedListener: (() -> Unit)? = null
+
+ var tappingEnabled = true
+ private set
+
+ var volumeKeysEnabled = false
+ private set
+
+ var volumeKeysInverted = false
+ private set
+
+ var imageCropBorders = false
+ private set
+
+ var doubleTapAnimDuration = 500
+ private set
+
+ init {
+ preferences.readWithTapping()
+ .register({ tappingEnabled = it })
+
+ preferences.cropBordersWebtoon()
+ .register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() })
+
+ preferences.doubleTapAnimSpeed()
+ .register({ doubleTapAnimDuration = it })
+
+ preferences.readWithVolumeKeys()
+ .register({ volumeKeysEnabled = it })
+
+ preferences.readWithVolumeKeysInverted()
+ .register({ volumeKeysInverted = it })
+ }
+
+ fun unsubscribe() {
+ subscriptions.unsubscribe()
+ }
+
+ private fun Preference.register(
+ valueAssignment: (T) -> Unit,
+ onChanged: (T) -> Unit = {}
+ ) {
+ asObservable()
+ .doOnNext(valueAssignment)
+ .skip(1)
+ .distinctUntilChanged()
+ .doOnNext(onChanged)
+ .subscribe()
+ .addTo(subscriptions)
+ }
+
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonFrame.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonFrame.kt
new file mode 100644
index 000000000..955dc898e
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonFrame.kt
@@ -0,0 +1,80 @@
+package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
+
+import android.content.Context
+import android.view.GestureDetector
+import android.view.MotionEvent
+import android.view.ScaleGestureDetector
+import android.widget.FrameLayout
+
+/**
+ * Frame layout which contains a [WebtoonRecyclerView]. It's needed to handle touch events,
+ * because the recyclerview is scaled and its touch events are translated, which breaks the
+ * detectors.
+ *
+ * TODO consider integrating this class into [WebtoonViewer].
+ */
+class WebtoonFrame(context: Context) : FrameLayout(context) {
+
+ /**
+ * Scale detector, either with pinch or quick scale.
+ */
+ private val scaleDetector = ScaleGestureDetector(context, ScaleListener())
+
+ /**
+ * Fling detector.
+ */
+ private val flingDetector = GestureDetector(context, FlingListener())
+
+ /**
+ * Recycler view added in this frame.
+ */
+ private val recycler: WebtoonRecyclerView?
+ get() = getChildAt(0) as? WebtoonRecyclerView
+
+ /**
+ * Dispatches a touch event to the detectors.
+ */
+ override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
+ scaleDetector.onTouchEvent(ev)
+ flingDetector.onTouchEvent(ev)
+ return super.dispatchTouchEvent(ev)
+ }
+
+ /**
+ * Scale listener used to delegate events to the recycler view.
+ */
+ inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
+ override fun onScaleBegin(detector: ScaleGestureDetector?): Boolean {
+ recycler?.onScaleBegin()
+ return true
+ }
+
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
+ recycler?.onScale(detector.scaleFactor)
+ return true
+ }
+
+ override fun onScaleEnd(detector: ScaleGestureDetector) {
+ recycler?.onScaleEnd()
+ }
+ }
+
+ /**
+ * Fling listener used to delegate events to the recycler view.
+ */
+ inner class FlingListener : GestureDetector.SimpleOnGestureListener() {
+ override fun onDown(e: MotionEvent?): Boolean {
+ return true
+ }
+
+ override fun onFling(
+ e1: MotionEvent?,
+ e2: MotionEvent?,
+ velocityX: Float,
+ velocityY: Float
+ ): Boolean {
+ return recycler?.zoomFling(velocityX.toInt(), velocityY.toInt()) ?: false
+ }
+ }
+
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonHolder.kt
deleted file mode 100644
index 34488d65e..000000000
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonHolder.kt
+++ /dev/null
@@ -1,316 +0,0 @@
-package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
-
-import android.view.MotionEvent
-import android.view.View
-import android.view.ViewGroup
-import android.view.ViewGroup.LayoutParams.MATCH_PARENT
-import android.widget.FrameLayout
-import com.davemorrissey.labs.subscaleview.ImageSource
-import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
-import com.hippo.unifile.UniFile
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.source.model.Page
-import eu.kanade.tachiyomi.ui.base.holder.BaseViewHolder
-import eu.kanade.tachiyomi.ui.reader.ReaderActivity
-import eu.kanade.tachiyomi.ui.reader.viewer.base.PageDecodeErrorLayout
-import eu.kanade.tachiyomi.util.inflate
-import kotlinx.android.synthetic.main.reader_webtoon_item.*
-import rx.Observable
-import rx.Subscription
-import rx.android.schedulers.AndroidSchedulers
-import rx.subjects.PublishSubject
-import rx.subjects.SerializedSubject
-import java.util.concurrent.TimeUnit
-
-/**
- * Holder for webtoon reader for a single page of a chapter.
- * All the elements from the layout file "reader_webtoon_item" are available in this class.
- *
- * @param view the inflated view for this holder.
- * @param adapter the adapter handling this holder.
- * @constructor creates a new webtoon holder.
- */
-class WebtoonHolder(private val view: View, private val adapter: WebtoonAdapter) :
- BaseViewHolder(view) {
-
- /**
- * Page of a chapter.
- */
- private var page: Page? = null
-
- /**
- * Subscription for status changes of the page.
- */
- private var statusSubscription: Subscription? = null
-
- /**
- * Subscription for progress changes of the page.
- */
- private var progressSubscription: Subscription? = null
-
- /**
- * Layout of decode error.
- */
- private var decodeErrorLayout: View? = null
-
- init {
- with(image_view) {
- setMaxTileSize(readerActivity.maxBitmapSize)
- setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_FIXED)
- setDoubleTapZoomDuration(webtoonReader.doubleTapAnimDuration.toInt())
- setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
- setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_FIT_WIDTH)
- setMinimumDpi(90)
- setMinimumTileDpi(180)
- setRegionDecoderClass(webtoonReader.regionDecoderClass)
- setBitmapDecoderClass(webtoonReader.bitmapDecoderClass)
- setCropBorders(webtoonReader.cropBorders)
- setVerticalScrollingParent(true)
- setOnTouchListener(adapter.touchListener)
- setOnLongClickListener { webtoonReader.onLongClick(page) }
- setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() {
- override fun onReady() {
- onImageDecoded()
- }
-
- override fun onImageLoadError(e: Exception) {
- onImageDecodeError()
- }
- })
- }
-
- progress_container.layoutParams = FrameLayout.LayoutParams(
- MATCH_PARENT, webtoonReader.screenHeight)
-
- view.setOnTouchListener(adapter.touchListener)
- retry_button.setOnTouchListener { _, event ->
- if (event.action == MotionEvent.ACTION_UP) {
- readerActivity.presenter.retryPage(page)
- }
- true
- }
- }
-
- /**
- * Method called from [WebtoonAdapter.onBindViewHolder]. It updates the data for this
- * holder with the given page.
- *
- * @param page the page to bind.
- */
- fun onSetValues(page: Page) {
- this.page = page
- observeStatus()
- }
-
- /**
- * Called when the view is recycled and added to the view pool.
- */
- fun onRecycle() {
- unsubscribeStatus()
- unsubscribeProgress()
- decodeErrorLayout?.let {
- (view as ViewGroup).removeView(it)
- decodeErrorLayout = null
- }
- image_view.recycle()
- image_view.visibility = View.GONE
- progress_container.visibility = View.VISIBLE
- }
-
- /**
- * Observes the status of the page and notify the changes.
- *
- * @see processStatus
- */
- private fun observeStatus() {
- unsubscribeStatus()
-
- val page = page ?: return
-
- val statusSubject = SerializedSubject(PublishSubject.create())
- page.setStatusSubject(statusSubject)
-
- statusSubscription = statusSubject.startWith(page.status)
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe { processStatus(it) }
-
- addSubscription(statusSubscription)
- }
-
- /**
- * Observes the progress of the page and updates view.
- */
- private fun observeProgress() {
- unsubscribeProgress()
-
- val page = page ?: return
-
- progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
- .map { page.progress }
- .distinctUntilChanged()
- .onBackpressureLatest()
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe { progress ->
- progress_text.text = if (progress > 0) {
- view.context.getString(R.string.download_progress, progress)
- } else {
- view.context.getString(R.string.downloading)
- }
- }
-
- addSubscription(progressSubscription)
- }
-
- /**
- * Called when the status of the page changes.
- *
- * @param status the new status of the page.
- */
- private fun processStatus(status: Int) {
- when (status) {
- Page.QUEUE -> setQueued()
- Page.LOAD_PAGE -> setLoading()
- Page.DOWNLOAD_IMAGE -> {
- observeProgress()
- setDownloading()
- }
- Page.READY -> {
- setImage()
- unsubscribeProgress()
- }
- Page.ERROR -> {
- setError()
- unsubscribeProgress()
- }
- }
- }
-
- /**
- * Adds a subscription to a list of subscriptions that will automatically unsubscribe when the
- * activity or the reader is destroyed.
- */
- private fun addSubscription(subscription: Subscription?) {
- webtoonReader.subscriptions.add(subscription)
- }
-
- /**
- * Removes a subscription from the list of subscriptions.
- */
- private fun removeSubscription(subscription: Subscription?) {
- subscription?.let { webtoonReader.subscriptions.remove(it) }
- }
-
- /**
- * Unsubscribes from the status subscription.
- */
- private fun unsubscribeStatus() {
- page?.setStatusSubject(null)
- removeSubscription(statusSubscription)
- statusSubscription = null
- }
-
- /**
- * Unsubscribes from the progress subscription.
- */
- private fun unsubscribeProgress() {
- removeSubscription(progressSubscription)
- progressSubscription = null
- }
-
- /**
- * Called when the page is queued.
- */
- private fun setQueued() = with(view) {
- progress_container.visibility = View.VISIBLE
- progress_text.visibility = View.INVISIBLE
- retry_container.visibility = View.GONE
- decodeErrorLayout?.let {
- (view as ViewGroup).removeView(it)
- decodeErrorLayout = null
- }
- }
-
- /**
- * Called when the page is loading.
- */
- private fun setLoading() = with(view) {
- progress_container.visibility = View.VISIBLE
- progress_text.visibility = View.VISIBLE
- progress_text.setText(R.string.downloading)
- }
-
- /**
- * Called when the page is downloading
- */
- private fun setDownloading() = with(view) {
- progress_container.visibility = View.VISIBLE
- progress_text.visibility = View.VISIBLE
- }
-
- /**
- * Called when the page is ready.
- */
- private fun setImage() = with(view) {
- val uri = page?.uri
- if (uri == null) {
- page?.status = Page.ERROR
- return
- }
-
- val file = UniFile.fromUri(context, uri)
- if (!file.exists()) {
- page?.status = Page.ERROR
- return
- }
-
- progress_text.visibility = View.INVISIBLE
- image_view.visibility = View.VISIBLE
- image_view.setImage(ImageSource.uri(file.uri))
- }
-
- /**
- * Called when the page has an error.
- */
- private fun setError() = with(view) {
- progress_container.visibility = View.GONE
- retry_container.visibility = View.VISIBLE
- }
-
- /**
- * Called when the image is decoded and going to be displayed.
- */
- private fun onImageDecoded() {
- progress_container.visibility = View.GONE
- }
-
- /**
- * Called when the image fails to decode.
- */
- private fun onImageDecodeError() {
- progress_container.visibility = View.GONE
-
- val page = page ?: return
- if (decodeErrorLayout != null || !webtoonReader.isAdded) return
-
- val layout = (view as ViewGroup).inflate(R.layout.reader_page_decode_error)
- PageDecodeErrorLayout(layout, page, readerActivity.readerTheme, {
- if (webtoonReader.isAdded) {
- readerActivity.presenter.retryPage(page)
- }
- })
- decodeErrorLayout = layout
- view.addView(layout)
- }
-
- /**
- * Property to get the reader activity.
- */
- private val readerActivity: ReaderActivity
- get() = adapter.fragment.readerActivity
-
- /**
- * Property to get the webtoon reader.
- */
- private val webtoonReader: WebtoonReader
- get() = adapter.fragment
-}
\ No newline at end of file
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt
new file mode 100644
index 000000000..c9cd0712c
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonLayoutManager.kt
@@ -0,0 +1,55 @@
+@file:Suppress("PackageDirectoryMismatch")
+
+package android.support.v7.widget
+
+import android.support.v7.widget.RecyclerView.NO_POSITION
+import eu.kanade.tachiyomi.ui.reader.ReaderActivity
+
+/**
+ * Layout manager used by the webtoon viewer. Item prefetch is disabled because the extra layout
+ * space feature is used which allows setting the image even if the holder is not visible,
+ * avoiding (in most cases) black views when they are visible.
+ *
+ * This layout manager uses the same package name as the support library in order to use a package
+ * protected method.
+ */
+class WebtoonLayoutManager(activity: ReaderActivity) : LinearLayoutManager(activity) {
+
+ /**
+ * Extra layout space is set to half the screen height.
+ */
+ private val extraLayoutSpace = activity.resources.displayMetrics.heightPixels / 2
+
+ init {
+ isItemPrefetchEnabled = false
+ }
+
+ /**
+ * Returns the custom extra layout space.
+ */
+ override fun getExtraLayoutSpace(state: RecyclerView.State): Int {
+ return extraLayoutSpace
+ }
+
+ /**
+ * Returns the position of the last item whose end side is visible on screen.
+ */
+ fun findLastEndVisibleItemPosition(): Int {
+ ensureLayoutState()
+ @ViewBoundsCheck.ViewBounds val preferredBoundsFlag =
+ (ViewBoundsCheck.FLAG_CVE_LT_PVE or ViewBoundsCheck.FLAG_CVE_EQ_PVE)
+
+ val fromIndex = childCount - 1
+ val toIndex = -1
+
+ val child = if (mOrientation == HORIZONTAL)
+ mHorizontalBoundCheck
+ .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0)
+ else
+ mVerticalBoundCheck
+ .findOneViewWithinBoundFlags(fromIndex, toIndex, preferredBoundsFlag, 0)
+
+ return if (child == null) NO_POSITION else getPosition(child)
+ }
+
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt
new file mode 100644
index 000000000..c6c187662
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt
@@ -0,0 +1,504 @@
+package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.graphics.drawable.Drawable
+import android.net.Uri
+import android.support.v7.widget.AppCompatButton
+import android.support.v7.widget.AppCompatImageView
+import android.view.Gravity
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import com.bumptech.glide.load.DataSource
+import com.bumptech.glide.load.engine.DiskCacheStrategy
+import com.bumptech.glide.load.engine.GlideException
+import com.bumptech.glide.request.RequestListener
+import com.bumptech.glide.request.target.Target
+import com.davemorrissey.labs.subscaleview.ImageSource
+import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.glide.GlideApp
+import eu.kanade.tachiyomi.source.model.Page
+import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
+import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressBar
+import eu.kanade.tachiyomi.util.ImageUtil
+import eu.kanade.tachiyomi.util.dpToPx
+import eu.kanade.tachiyomi.util.gone
+import eu.kanade.tachiyomi.util.visible
+import rx.Observable
+import rx.Subscription
+import rx.android.schedulers.AndroidSchedulers
+import rx.schedulers.Schedulers
+import java.io.InputStream
+import java.util.concurrent.TimeUnit
+
+/**
+ * Holder of the webtoon reader for a single page of a chapter.
+ *
+ * @param frame the root view for this holder.
+ * @param viewer the webtoon viewer.
+ * @constructor creates a new webtoon holder.
+ */
+class WebtoonPageHolder(
+ private val frame: FrameLayout,
+ viewer: WebtoonViewer
+) : WebtoonBaseHolder(frame, viewer) {
+
+ /**
+ * Loading progress bar to indicate the current progress.
+ */
+ private val progressBar = createProgressBar()
+
+ /**
+ * Progress bar container. Needed to keep a minimum height size of the holder, otherwise the
+ * adapter would create more views to fill the screen, which is not wanted.
+ */
+ private lateinit var progressContainer: ViewGroup
+
+ /**
+ * Image view that supports subsampling on zoom.
+ */
+ private var subsamplingImageView: SubsamplingScaleImageView? = null
+
+ /**
+ * Simple image view only used on GIFs.
+ */
+ private var imageView: ImageView? = null
+
+ /**
+ * Retry button container used to allow retrying.
+ */
+ private var retryContainer: ViewGroup? = null
+
+ /**
+ * Error layout to show when the image fails to decode.
+ */
+ private var decodeErrorLayout: ViewGroup? = null
+
+ /**
+ * Getter to retrieve the height of the recycler view.
+ */
+ private val parentHeight
+ get() = viewer.recycler.height
+
+ /**
+ * Page of a chapter.
+ */
+ private var page: ReaderPage? = null
+
+ /**
+ * Subscription for status changes of the page.
+ */
+ private var statusSubscription: Subscription? = null
+
+ /**
+ * Subscription for progress changes of the page.
+ */
+ private var progressSubscription: Subscription? = null
+
+ /**
+ * Subscription used to read the header of the image. This is needed in order to instantiate
+ * the appropiate image view depending if the image is animated (GIF).
+ */
+ private var readImageHeaderSubscription: Subscription? = null
+
+ init {
+ frame.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+ }
+
+ /**
+ * Binds the given [page] with this view holder, subscribing to its state.
+ */
+ fun bind(page: ReaderPage) {
+ this.page = page
+ observeStatus()
+ }
+
+ /**
+ * Called when the view is recycled and added to the view pool.
+ */
+ override fun recycle() {
+ unsubscribeStatus()
+ unsubscribeProgress()
+ unsubscribeReadImageHeader()
+
+ removeDecodeErrorLayout()
+ subsamplingImageView?.recycle()
+ subsamplingImageView?.gone()
+ imageView?.let { GlideApp.with(frame).clear(it) }
+ imageView?.gone()
+ progressBar.setProgress(0)
+ }
+
+ /**
+ * Observes the status of the page and notify the changes.
+ *
+ * @see processStatus
+ */
+ private fun observeStatus() {
+ unsubscribeStatus()
+
+ val page = page ?: return
+ val loader = page.chapter.pageLoader ?: return
+ statusSubscription = loader.getPage(page)
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe { processStatus(it) }
+
+ addSubscription(statusSubscription)
+ }
+
+ /**
+ * Observes the progress of the page and updates view.
+ */
+ private fun observeProgress() {
+ unsubscribeProgress()
+
+ val page = page ?: return
+
+ progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
+ .map { page.progress }
+ .distinctUntilChanged()
+ .onBackpressureLatest()
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe { value -> progressBar.setProgress(value) }
+
+ addSubscription(progressSubscription)
+ }
+
+ /**
+ * Called when the status of the page changes.
+ *
+ * @param status the new status of the page.
+ */
+ private fun processStatus(status: Int) {
+ when (status) {
+ Page.QUEUE -> setQueued()
+ Page.LOAD_PAGE -> setLoading()
+ Page.DOWNLOAD_IMAGE -> {
+ observeProgress()
+ setDownloading()
+ }
+ Page.READY -> {
+ setImage()
+ unsubscribeProgress()
+ }
+ Page.ERROR -> {
+ setError()
+ unsubscribeProgress()
+ }
+ }
+ }
+
+ /**
+ * Unsubscribes from the status subscription.
+ */
+ private fun unsubscribeStatus() {
+ removeSubscription(statusSubscription)
+ statusSubscription = null
+ }
+
+ /**
+ * Unsubscribes from the progress subscription.
+ */
+ private fun unsubscribeProgress() {
+ removeSubscription(progressSubscription)
+ progressSubscription = null
+ }
+
+ /**
+ * Unsubscribes from the read image header subscription.
+ */
+ private fun unsubscribeReadImageHeader() {
+ removeSubscription(readImageHeaderSubscription)
+ readImageHeaderSubscription = null
+ }
+
+ /**
+ * Called when the page is queued.
+ */
+ private fun setQueued() {
+ progressContainer.visible()
+ progressBar.visible()
+ retryContainer?.gone()
+ removeDecodeErrorLayout()
+ }
+
+ /**
+ * Called when the page is loading.
+ */
+ private fun setLoading() {
+ progressContainer.visible()
+ progressBar.visible()
+ retryContainer?.gone()
+ removeDecodeErrorLayout()
+ }
+
+ /**
+ * Called when the page is downloading
+ */
+ private fun setDownloading() {
+ progressContainer.visible()
+ progressBar.visible()
+ retryContainer?.gone()
+ removeDecodeErrorLayout()
+ }
+
+ /**
+ * Called when the page is ready.
+ */
+ private fun setImage() {
+ progressContainer.visible()
+ progressBar.visible()
+ progressBar.completeAndFadeOut()
+ retryContainer?.gone()
+ removeDecodeErrorLayout()
+
+ unsubscribeReadImageHeader()
+ val streamFn = page?.stream ?: return
+
+ var openStream: InputStream? = null
+ readImageHeaderSubscription = Observable
+ .fromCallable {
+ val stream = streamFn().buffered(16)
+ openStream = stream
+
+ ImageUtil.findImageType(stream) == ImageUtil.ImageType.GIF
+ }
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .doOnNext { isAnimated ->
+ if (!isAnimated) {
+ val subsamplingView = initSubsamplingImageView()
+ subsamplingView.visible()
+ subsamplingView.setImage(ImageSource.inputStream(openStream!!))
+ } else {
+ val imageView = initImageView()
+ imageView.visible()
+ imageView.setImage(openStream!!)
+ }
+ }
+ // Keep the Rx stream alive to close the input stream only when unsubscribed
+ .flatMap { Observable.never() }
+ .doOnUnsubscribe { openStream?.close() }
+ .subscribe({}, {})
+
+ addSubscription(readImageHeaderSubscription)
+ }
+
+ /**
+ * Called when the page has an error.
+ */
+ private fun setError() {
+ progressContainer.gone()
+ initRetryLayout().visible()
+ }
+
+ /**
+ * Called when the image is decoded and going to be displayed.
+ */
+ private fun onImageDecoded() {
+ progressContainer.gone()
+ }
+
+ /**
+ * Called when the image fails to decode.
+ */
+ private fun onImageDecodeError() {
+ progressContainer.gone()
+ initDecodeErrorLayout().visible()
+ }
+
+ /**
+ * Creates a new progress bar.
+ */
+ @SuppressLint("PrivateResource")
+ private fun createProgressBar(): ReaderProgressBar {
+ progressContainer = FrameLayout(context)
+ frame.addView(progressContainer, MATCH_PARENT, parentHeight)
+
+ val progress = ReaderProgressBar(context).apply {
+ val size = 48.dpToPx
+ layoutParams = FrameLayout.LayoutParams(size, size).apply {
+ gravity = Gravity.CENTER_HORIZONTAL
+ setMargins(0, parentHeight/4, 0, 0)
+ }
+ }
+ progressContainer.addView(progress)
+ return progress
+ }
+
+ /**
+ * Initializes a subsampling scale view.
+ */
+ private fun initSubsamplingImageView(): SubsamplingScaleImageView {
+ if (subsamplingImageView != null) return subsamplingImageView!!
+
+ val config = viewer.config
+
+ subsamplingImageView = WebtoonSubsamplingImageView(context).apply {
+ setMaxTileSize(viewer.activity.maxBitmapSize)
+ setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE)
+ setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_FIT_WIDTH)
+ setMinimumDpi(90)
+ setMinimumTileDpi(180)
+ setCropBorders(config.imageCropBorders)
+ setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() {
+ override fun onReady() {
+ onImageDecoded()
+ }
+
+ override fun onImageLoadError(e: Exception) {
+ onImageDecodeError()
+ }
+ })
+ }
+ frame.addView(subsamplingImageView, MATCH_PARENT, MATCH_PARENT)
+ return subsamplingImageView!!
+ }
+
+ /**
+ * Initializes an image view, used for GIFs.
+ */
+ private fun initImageView(): ImageView {
+ if (imageView != null) return imageView!!
+
+ imageView = AppCompatImageView(context).apply {
+ adjustViewBounds = true
+ }
+ frame.addView(imageView, MATCH_PARENT, MATCH_PARENT)
+ return imageView!!
+ }
+
+ /**
+ * Initializes a button to retry pages.
+ */
+ private fun initRetryLayout(): ViewGroup {
+ if (retryContainer != null) return retryContainer!!
+
+ retryContainer = FrameLayout(context)
+ frame.addView(retryContainer, MATCH_PARENT, parentHeight)
+
+ AppCompatButton(context).apply {
+ layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
+ gravity = Gravity.CENTER_HORIZONTAL
+ setMargins(0, parentHeight/4, 0, 0)
+ }
+ setText(R.string.action_retry)
+ setOnClickListener {
+ page?.let { it.chapter.pageLoader?.retryPage(it) }
+ }
+
+ retryContainer!!.addView(this)
+ }
+ return retryContainer!!
+ }
+
+ /**
+ * Initializes a decode error layout.
+ */
+ private fun initDecodeErrorLayout(): ViewGroup {
+ if (decodeErrorLayout != null) return decodeErrorLayout!!
+
+ val margins = 8.dpToPx
+
+ val decodeLayout = LinearLayout(context).apply {
+ layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, parentHeight).apply {
+ setMargins(0, parentHeight/6, 0, 0)
+ }
+ gravity = Gravity.CENTER_HORIZONTAL
+ orientation = LinearLayout.VERTICAL
+ }
+ decodeErrorLayout = decodeLayout
+
+ TextView(context).apply {
+ layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
+ setMargins(0, margins, 0, margins)
+ }
+ gravity = Gravity.CENTER
+ setText(R.string.decode_image_error)
+
+ decodeLayout.addView(this)
+ }
+
+ AppCompatButton(context).apply {
+ layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
+ setMargins(0, margins, 0, margins)
+ }
+ setText(R.string.action_retry)
+ setOnClickListener {
+ page?.let { it.chapter.pageLoader?.retryPage(it) }
+ }
+
+ decodeLayout.addView(this)
+ }
+
+ val imageUrl = page?.imageUrl
+ if (imageUrl.orEmpty().startsWith("http")) {
+ AppCompatButton(context).apply {
+ layoutParams = FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
+ setMargins(0, margins, 0, margins)
+ }
+ setText(R.string.action_open_in_browser)
+ setOnClickListener {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(imageUrl))
+ context.startActivity(intent)
+ }
+
+ decodeLayout.addView(this)
+ }
+ }
+
+ frame.addView(decodeLayout)
+ return decodeLayout
+ }
+
+ /**
+ * Removes the decode error layout from the holder, if found.
+ */
+ private fun removeDecodeErrorLayout() {
+ val layout = decodeErrorLayout
+ if (layout != null) {
+ frame.removeView(layout)
+ decodeErrorLayout = null
+ }
+ }
+
+ /**
+ * Extension method to set a [stream] into this ImageView.
+ */
+ private fun ImageView.setImage(stream: InputStream) {
+ GlideApp.with(this)
+ .load(stream)
+ .skipMemoryCache(true)
+ .diskCacheStrategy(DiskCacheStrategy.NONE)
+ .listener(object : RequestListener {
+ override fun onLoadFailed(
+ e: GlideException?,
+ model: Any?,
+ target: Target?,
+ isFirstResource: Boolean
+ ): Boolean {
+ onImageDecodeError()
+ return false
+ }
+
+ override fun onResourceReady(
+ resource: Drawable?,
+ model: Any?,
+ target: Target?,
+ dataSource: DataSource?,
+ isFirstResource: Boolean
+ ): Boolean {
+ onImageDecoded()
+ return false
+ }
+ })
+ .into(this)
+ }
+
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonReader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonReader.kt
deleted file mode 100644
index 3ac0c5085..000000000
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonReader.kt
+++ /dev/null
@@ -1,263 +0,0 @@
-package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
-
-import android.os.Build
-import android.os.Bundle
-import android.support.v7.widget.RecyclerView
-import android.util.DisplayMetrics
-import android.view.Display
-import android.view.GestureDetector
-import android.view.LayoutInflater
-import android.view.MotionEvent
-import android.view.View
-import android.view.ViewGroup
-import android.view.ViewGroup.LayoutParams.MATCH_PARENT
-import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
-import eu.kanade.tachiyomi.source.model.Page
-import eu.kanade.tachiyomi.ui.reader.ReaderChapter
-import eu.kanade.tachiyomi.ui.reader.viewer.base.BaseReader
-import eu.kanade.tachiyomi.widget.PreCachingLayoutManager
-import rx.subscriptions.CompositeSubscription
-
-/**
- * Implementation of a reader for webtoons based on a RecyclerView.
- */
-class WebtoonReader : BaseReader() {
-
- companion object {
- /**
- * Key to save and restore the position of the layout manager.
- */
- private val SAVED_POSITION = "saved_position"
-
- /**
- * Left side region of the screen. Used for touch events.
- */
- private val LEFT_REGION = 0.33f
-
- /**
- * Right side region of the screen. Used for touch events.
- */
- private val RIGHT_REGION = 0.66f
- }
-
- /**
- * RecyclerView of the reader.
- */
- lateinit var recycler: RecyclerView
- private set
-
- /**
- * Adapter of the recycler.
- */
- lateinit var adapter: WebtoonAdapter
- private set
-
- /**
- * Layout manager of the recycler.
- */
- lateinit var layoutManager: PreCachingLayoutManager
- private set
-
- /**
- * Whether to crop image borders.
- */
- var cropBorders: Boolean = false
- private set
-
- /**
- * Duration of the double tap animation
- */
- var doubleTapAnimDuration = 500
- private set
-
- /**
- * Gesture detector for image touch events.
- */
- val imageGestureDetector by lazy { GestureDetector(context, ImageGestureListener()) }
-
- /**
- * Subscriptions used while the view exists.
- */
- lateinit var subscriptions: CompositeSubscription
- private set
-
- private var scrollDistance: Int = 0
-
- val screenHeight by lazy {
- val display = activity!!.windowManager.defaultDisplay
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
- val metrics = DisplayMetrics()
- display.getRealMetrics(metrics)
- metrics.heightPixels
- } else {
- val field = Display::class.java.getMethod("getRawHeight")
- field.invoke(display) as Int
- }
- }
-
- override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
- adapter = WebtoonAdapter(this)
-
- val screenHeight = resources.displayMetrics.heightPixels
- scrollDistance = screenHeight * 3 / 4
-
- layoutManager = PreCachingLayoutManager(activity!!)
- layoutManager.extraLayoutSpace = screenHeight / 2
-
- recycler = RecyclerView(activity).apply {
- layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
- itemAnimator = null
- }
- recycler.layoutManager = layoutManager
- recycler.adapter = adapter
- recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
- override fun onScrolled(recyclerView: RecyclerView?, dx: Int, dy: Int) {
- val index = layoutManager.findLastVisibleItemPosition()
- if (index != currentPage) {
- pages.getOrNull(index)?.let { onPageChanged(index) }
- }
- }
- })
-
- subscriptions = CompositeSubscription()
- subscriptions.add(readerActivity.preferences.imageDecoder()
- .asObservable()
- .doOnNext { setDecoderClass(it) }
- .skip(1)
- .distinctUntilChanged()
- .subscribe { refreshAdapter() })
-
- subscriptions.add(readerActivity.preferences.cropBordersWebtoon()
- .asObservable()
- .doOnNext { cropBorders = it }
- .skip(1)
- .distinctUntilChanged()
- .subscribe { refreshAdapter() })
-
- subscriptions.add(readerActivity.preferences.doubleTapAnimSpeed()
- .asObservable()
- .subscribe { doubleTapAnimDuration = it })
-
- setPagesOnAdapter()
- return recycler
- }
-
- fun refreshAdapter() {
- val activePage = layoutManager.findFirstVisibleItemPosition()
- recycler.adapter = adapter
- setActivePage(activePage)
- }
-
- /**
- * Uses two ways to scroll to the last page read.
- */
- private fun scrollToLastPageRead(page: Int) {
- // Scrolls to the correct page initially, but isn't reliable beyond that.
- recycler.addOnLayoutChangeListener(object: View.OnLayoutChangeListener {
- override fun onLayoutChange(p0: View?, p1: Int, p2: Int, p3: Int, p4: Int, p5: Int, p6: Int, p7: Int, p8: Int) {
- if(pages.isEmpty()) {
- setActivePage(page)
- } else {
- recycler.removeOnLayoutChangeListener(this)
- }
- }
- })
-
- // Scrolls to the correct page after app has been in use, but can't do it the very first time.
- recycler.post { setActivePage(page) }
- }
-
- override fun onDestroyView() {
- subscriptions.unsubscribe()
- super.onDestroyView()
- }
-
- override fun onSaveInstanceState(outState: Bundle) {
- val savedPosition = pages.getOrNull(layoutManager.findFirstVisibleItemPosition())?.index ?: 0
- outState.putInt(SAVED_POSITION, savedPosition)
- super.onSaveInstanceState(outState)
- }
-
- /**
- * Gesture detector for Subsampling Scale Image View.
- */
- inner class ImageGestureListener : GestureDetector.SimpleOnGestureListener() {
-
- override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
- if (isAdded) {
- val positionX = e.x
-
- if (positionX < recycler.width * LEFT_REGION) {
- if (tappingEnabled) moveLeft()
- } else if (positionX > recycler.width * RIGHT_REGION) {
- if (tappingEnabled) moveRight()
- } else {
- readerActivity.toggleMenu()
- }
- }
- return true
- }
- }
-
- /**
- * Called when a new chapter is set in [BaseReader].
- * @param chapter the chapter set.
- * @param currentPage the initial page to display.
- */
- override fun onChapterSet(chapter: ReaderChapter, currentPage: Page) {
- this.currentPage = currentPage.index
-
- // Make sure the view is already initialized.
- if (view != null) {
- setPagesOnAdapter()
- scrollToLastPageRead(this.currentPage)
- }
- }
-
- /**
- * Called when a chapter is appended in [BaseReader].
- * @param chapter the chapter appended.
- */
- override fun onChapterAppended(chapter: ReaderChapter) {
- // Make sure the view is already initialized.
- if (view != null) {
- val insertStart = pages.size - chapter.pages!!.size
- adapter.notifyItemRangeInserted(insertStart, chapter.pages!!.size)
- }
- }
-
- /**
- * Sets the pages on the adapter.
- */
- private fun setPagesOnAdapter() {
- if (pages.isNotEmpty()) {
- adapter.pages = pages
- recycler.adapter = adapter
- onPageChanged(currentPage)
- }
- }
-
- /**
- * Sets the active page.
- * @param pageNumber the index of the page from [pages].
- */
- override fun setActivePage(pageNumber: Int) {
- recycler.scrollToPosition(pageNumber)
- }
-
- /**
- * Moves to the next page or requests the next chapter if it's the last one.
- */
- override fun moveRight() {
- recycler.smoothScrollBy(0, scrollDistance)
- }
-
- /**
- * Moves to the previous page or requests the previous chapter if it's the first one.
- */
- override fun moveLeft() {
- recycler.smoothScrollBy(0, -scrollDistance)
- }
-
-}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt
new file mode 100644
index 000000000..f838e62ed
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonRecyclerView.kt
@@ -0,0 +1,325 @@
+package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
+
+import android.animation.Animator
+import android.animation.AnimatorSet
+import android.animation.ValueAnimator
+import android.annotation.TargetApi
+import android.content.Context
+import android.os.Build
+import android.support.v7.widget.LinearLayoutManager
+import android.support.v7.widget.RecyclerView
+import android.util.AttributeSet
+import android.view.HapticFeedbackConstants
+import android.view.MotionEvent
+import android.view.ViewConfiguration
+import android.view.animation.DecelerateInterpolator
+import eu.kanade.tachiyomi.ui.reader.viewer.GestureDetectorWithLongTap
+
+/**
+ * Implementation of a [RecyclerView] used by the webtoon reader.
+ */
+open class WebtoonRecyclerView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyle: Int = 0
+) : RecyclerView(context, attrs, defStyle) {
+
+ private var isZooming = false
+ private var atLastPosition = false
+ private var atFirstPosition = false
+ private var halfWidth = 0
+ private var halfHeight = 0
+ private var firstVisibleItemPosition = 0
+ private var lastVisibleItemPosition = 0
+ private var currentScale = DEFAULT_RATE
+
+ private val listener = GestureListener()
+ private val detector = Detector()
+
+ var tapListener: ((MotionEvent) -> Unit)? = null
+ var longTapListener: ((MotionEvent) -> Unit)? = null
+
+ override fun onMeasure(widthSpec: Int, heightSpec: Int) {
+ halfWidth = MeasureSpec.getSize(widthSpec) / 2
+ halfHeight = MeasureSpec.getSize(heightSpec) / 2
+ super.onMeasure(widthSpec, heightSpec)
+ }
+
+ override fun onTouchEvent(e: MotionEvent): Boolean {
+ detector.onTouchEvent(e)
+ return super.onTouchEvent(e)
+ }
+
+ override fun onScrolled(dx: Int, dy: Int) {
+ super.onScrolled(dx, dy)
+ val layoutManager = layoutManager
+ lastVisibleItemPosition =
+ (layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
+ firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
+ }
+
+ @TargetApi(Build.VERSION_CODES.KITKAT)
+ override fun onScrollStateChanged(state: Int) {
+ super.onScrollStateChanged(state)
+ val layoutManager = layoutManager
+ val visibleItemCount = layoutManager.childCount
+ val totalItemCount = layoutManager.itemCount
+ atLastPosition = visibleItemCount > 0 && lastVisibleItemPosition == totalItemCount - 1
+ atFirstPosition = firstVisibleItemPosition == 0
+ }
+
+ private fun getPositionX(positionX: Float): Float {
+ val maxPositionX = halfWidth * (currentScale - 1)
+ return positionX.coerceIn(-maxPositionX, maxPositionX)
+ }
+
+ private fun getPositionY(positionY: Float): Float {
+ val maxPositionY = halfHeight * (currentScale - 1)
+ return positionY.coerceIn(-maxPositionY, maxPositionY)
+ }
+
+ private fun zoom(
+ fromRate: Float,
+ toRate: Float,
+ fromX: Float,
+ toX: Float,
+ fromY: Float,
+ toY: Float
+ ) {
+ isZooming = true
+ val animatorSet = AnimatorSet()
+ val translationXAnimator = ValueAnimator.ofFloat(fromX, toX)
+ translationXAnimator.addUpdateListener { animation -> x = animation.animatedValue as Float }
+
+ val translationYAnimator = ValueAnimator.ofFloat(fromY, toY)
+ translationYAnimator.addUpdateListener { animation -> y = animation.animatedValue as Float }
+
+ val scaleAnimator = ValueAnimator.ofFloat(fromRate, toRate)
+ scaleAnimator.addUpdateListener { animation ->
+ setScaleRate(animation.animatedValue as Float)
+ }
+ animatorSet.playTogether(translationXAnimator, translationYAnimator, scaleAnimator)
+ animatorSet.duration = ANIMATOR_DURATION_TIME.toLong()
+ animatorSet.interpolator = DecelerateInterpolator()
+ animatorSet.start()
+ animatorSet.addListener(object : Animator.AnimatorListener {
+ override fun onAnimationStart(animation: Animator) {
+
+ }
+
+ override fun onAnimationEnd(animation: Animator) {
+ isZooming = false
+ currentScale = toRate
+ }
+
+ override fun onAnimationCancel(animation: Animator) {
+
+ }
+
+ override fun onAnimationRepeat(animation: Animator) {
+
+ }
+ })
+ }
+
+ fun zoomFling(velocityX: Int, velocityY: Int): Boolean {
+ if (currentScale <= 1f) return false
+
+ val distanceTimeFactor = 0.4f
+ var newX: Float? = null
+ var newY: Float? = null
+
+ if (velocityX != 0) {
+ val dx = (distanceTimeFactor * velocityX / 2)
+ newX = getPositionX(x + dx)
+ }
+ if (velocityY != 0 && (atFirstPosition || atLastPosition)) {
+ val dy = (distanceTimeFactor * velocityY / 2)
+ newY = getPositionY(y + dy)
+ }
+
+ animate()
+ .apply {
+ newX?.let { x(it) }
+ newY?.let { y(it) }
+ }
+ .setInterpolator(DecelerateInterpolator())
+ .setDuration(400)
+ .start()
+
+ return true
+ }
+
+ private fun zoomScrollBy(dx: Int, dy: Int) {
+ if (dx != 0) {
+ x = getPositionX(x + dx)
+ }
+ if (dy != 0) {
+ y = getPositionY(y + dy)
+ }
+ }
+
+ private fun setScaleRate(rate: Float) {
+ scaleX = rate
+ scaleY = rate
+ }
+
+ fun onScale(scaleFactor: Float) {
+ currentScale *= scaleFactor
+ currentScale = currentScale.coerceIn(
+ DEFAULT_RATE,
+ MAX_SCALE_RATE)
+
+ setScaleRate(currentScale)
+
+ if (currentScale != DEFAULT_RATE) {
+ x = getPositionX(x)
+ y = getPositionY(y)
+ } else {
+ x = 0f
+ y = 0f
+ }
+ }
+
+ fun onScaleBegin() {
+ if (detector.isDoubleTapping) {
+ detector.isQuickScaling = true
+ }
+ }
+
+ fun onScaleEnd() {
+ if (scaleX < DEFAULT_RATE) {
+ zoom(currentScale, DEFAULT_RATE, x, 0f, y, 0f)
+ }
+ }
+
+ inner class GestureListener : GestureDetectorWithLongTap.Listener() {
+
+ override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
+ tapListener?.invoke(ev)
+ return false
+ }
+
+ override fun onDoubleTap(ev: MotionEvent): Boolean {
+ detector.isDoubleTapping = true
+ return false
+ }
+
+ fun onDoubleTapConfirmed(ev: MotionEvent) {
+ if (!isZooming) {
+ if (scaleX != DEFAULT_RATE) {
+ zoom(currentScale, DEFAULT_RATE, x, 0f, y, 0f)
+ } else {
+ val toScale = 2f
+ val toX = (halfWidth - ev.x) * (toScale - 1)
+ val toY = (halfHeight - ev.y) * (toScale - 1)
+ zoom(DEFAULT_RATE, toScale, 0f, toX, 0f, toY)
+ }
+ }
+ }
+
+ override fun onLongTapConfirmed(ev: MotionEvent) {
+ val listener = longTapListener
+ if (listener != null) {
+ listener.invoke(ev)
+ performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
+ }
+ }
+
+ }
+
+ inner class Detector : GestureDetectorWithLongTap(context, listener) {
+
+ private var scrollPointerId = 0
+ private var downX = 0
+ private var downY = 0
+ private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
+ private var isZoomDragging = false
+ var isDoubleTapping = false
+ var isQuickScaling = false
+
+ override fun onTouchEvent(ev: MotionEvent): Boolean {
+ val action = ev.actionMasked
+ val actionIndex = ev.actionIndex
+
+ when (action) {
+ MotionEvent.ACTION_DOWN -> {
+ scrollPointerId = ev.getPointerId(0)
+ downX = (ev.x + 0.5f).toInt()
+ downY = (ev.y + 0.5f).toInt()
+ }
+ MotionEvent.ACTION_POINTER_DOWN -> {
+ scrollPointerId = ev.getPointerId(actionIndex)
+ downX = (ev.getX(actionIndex) + 0.5f).toInt()
+ downY = (ev.getY(actionIndex) + 0.5f).toInt()
+ }
+ MotionEvent.ACTION_MOVE -> {
+ if (isDoubleTapping && isQuickScaling) {
+ return true
+ }
+
+ val index = ev.findPointerIndex(scrollPointerId)
+ if (index < 0) {
+ return false
+ }
+
+ val x = (ev.getX(index) + 0.5f).toInt()
+ val y = (ev.getY(index) + 0.5f).toInt()
+ var dx = x - downX
+ var dy = if (atFirstPosition || atLastPosition) y - downY else 0
+
+ if (!isZoomDragging && currentScale > 1f) {
+ var startScroll = false
+
+ if (Math.abs(dx) > touchSlop) {
+ if (dx < 0) {
+ dx += touchSlop
+ } else {
+ dx -= touchSlop
+ }
+ startScroll = true
+ }
+ if (Math.abs(dy) > touchSlop) {
+ if (dy < 0) {
+ dy += touchSlop
+ } else {
+ dy -= touchSlop
+ }
+ startScroll = true
+ }
+
+ if (startScroll) {
+ isZoomDragging = true
+ }
+ }
+
+ if (isZoomDragging) {
+ zoomScrollBy(dx, dy)
+ }
+ }
+ MotionEvent.ACTION_UP -> {
+ if (isDoubleTapping && !isQuickScaling) {
+ listener.onDoubleTapConfirmed(ev)
+ }
+ isZoomDragging = false
+ isDoubleTapping = false
+ isQuickScaling = false
+ }
+ MotionEvent.ACTION_CANCEL -> {
+ isZoomDragging = false
+ isDoubleTapping = false
+ isQuickScaling = false
+ }
+ }
+ return super.onTouchEvent(ev)
+ }
+
+ }
+
+ private companion object {
+ const val ANIMATOR_DURATION_TIME = 200
+ const val DEFAULT_RATE = 1f
+ const val MAX_SCALE_RATE = 3f
+ }
+
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonSubsamplingImageView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonSubsamplingImageView.kt
new file mode 100644
index 000000000..692c0596b
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonSubsamplingImageView.kt
@@ -0,0 +1,21 @@
+package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.MotionEvent
+import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
+
+/**
+ * Implementation of subsampling scale image view that ignores all touch events, because the
+ * webtoon viewer handles all the gestures.
+ */
+class WebtoonSubsamplingImageView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null
+) : SubsamplingScaleImageView(context, attrs) {
+
+ override fun onTouchEvent(event: MotionEvent): Boolean {
+ return false
+ }
+
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt
new file mode 100644
index 000000000..390615503
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt
@@ -0,0 +1,195 @@
+package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
+
+import android.support.v7.widget.AppCompatButton
+import android.support.v7.widget.AppCompatTextView
+import android.view.Gravity
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import android.widget.LinearLayout
+import android.widget.ProgressBar
+import android.widget.TextView
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
+import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
+import eu.kanade.tachiyomi.util.dpToPx
+import eu.kanade.tachiyomi.util.visibleIf
+import rx.Subscription
+import rx.android.schedulers.AndroidSchedulers
+
+/**
+ * Holder of the webtoon viewer that contains a chapter transition.
+ */
+class WebtoonTransitionHolder(
+ val layout: LinearLayout,
+ viewer: WebtoonViewer
+) : WebtoonBaseHolder(layout, viewer) {
+
+ /**
+ * Subscription for status changes of the transition page.
+ */
+ private var statusSubscription: Subscription? = null
+
+ /**
+ * Text view used to display the text of the current and next/prev chapters.
+ */
+ private var textView = TextView(context)
+
+ /**
+ * View container of the current status of the transition page. Child views will be added
+ * dynamically.
+ */
+ private var pagesContainer = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER
+ }
+
+ init {
+ layout.layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
+ layout.orientation = LinearLayout.VERTICAL
+ layout.gravity = Gravity.CENTER
+
+ val paddingVertical = 48.dpToPx
+ val paddingHorizontal = 32.dpToPx
+ layout.setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical)
+
+ val childMargins = 16.dpToPx
+ val childParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
+ setMargins(0, childMargins, 0, childMargins)
+ }
+
+ layout.addView(textView, childParams)
+ layout.addView(pagesContainer, childParams)
+ }
+
+ /**
+ * Binds the given [transition] with this view holder, subscribing to its state.
+ */
+ fun bind(transition: ChapterTransition) {
+ when (transition) {
+ is ChapterTransition.Prev -> bindPrevChapterTransition(transition)
+ is ChapterTransition.Next -> bindNextChapterTransition(transition)
+ }
+ }
+
+ /**
+ * Called when the view is recycled and being added to the view pool.
+ */
+ override fun recycle() {
+ unsubscribeStatus()
+ }
+
+ /**
+ * Binds a next chapter transition on this view and subscribes to the load status.
+ */
+ private fun bindNextChapterTransition(transition: ChapterTransition.Next) {
+ val nextChapter = transition.to
+
+ textView.text = if (nextChapter != null) {
+ context.getString(R.string.transition_finished, transition.from.chapter.name) + "\n\n" +
+ context.getString(R.string.transition_next, nextChapter.chapter.name)
+ } else {
+ context.getString(R.string.transition_no_next)
+ }
+
+ if (nextChapter != null) {
+ observeStatus(nextChapter, transition)
+ }
+ }
+
+ /**
+ * Binds a previous chapter transition on this view and subscribes to the page load status.
+ */
+ private fun bindPrevChapterTransition(transition: ChapterTransition.Prev) {
+ val prevChapter = transition.to
+
+ textView.text = if (prevChapter != null) {
+ context.getString(R.string.transition_current, transition.from.chapter.name) + "\n\n" +
+ context.getString(R.string.transition_previous, prevChapter.chapter.name)
+ } else {
+ context.getString(R.string.transition_no_previous)
+ }
+
+ if (prevChapter != null) {
+ observeStatus(prevChapter, transition)
+ }
+ }
+
+ /**
+ * Observes the status of the page list of the next/previous chapter. Whenever there's a new
+ * state, the pages container is cleaned up before setting the new state.
+ */
+ private fun observeStatus(chapter: ReaderChapter, transition: ChapterTransition) {
+ unsubscribeStatus()
+
+ statusSubscription = chapter.stateObserver
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe { state ->
+ pagesContainer.removeAllViews()
+ when (state) {
+ is ReaderChapter.State.Wait -> {}
+ is ReaderChapter.State.Loading -> setLoading()
+ is ReaderChapter.State.Error -> setError(state.error, transition)
+ is ReaderChapter.State.Loaded -> setLoaded()
+ }
+ pagesContainer.visibleIf { pagesContainer.childCount > 0 }
+ }
+
+ addSubscription(statusSubscription)
+ }
+
+ /**
+ * Unsubscribes from the status subscription.
+ */
+ private fun unsubscribeStatus() {
+ removeSubscription(statusSubscription)
+ statusSubscription = null
+ }
+
+ /**
+ * Sets the loading state on the pages container.
+ */
+ private fun setLoading() {
+ val progress = ProgressBar(context, null, android.R.attr.progressBarStyle)
+
+ val textView = AppCompatTextView(context).apply {
+ wrapContent()
+ setText(R.string.transition_pages_loading)
+ }
+
+ pagesContainer.addView(progress)
+ pagesContainer.addView(textView)
+ }
+
+ /**
+ * Sets the loaded state on the pages container.
+ */
+ private fun setLoaded() {
+ // No additional view is added
+ }
+
+ /**
+ * Sets the error state on the pages container.
+ */
+ private fun setError(error: Throwable, transition: ChapterTransition) {
+ val textView = AppCompatTextView(context).apply {
+ wrapContent()
+ text = context.getString(R.string.transition_pages_error, error.message)
+ }
+
+ val retryBtn = AppCompatButton(context).apply {
+ wrapContent()
+ setText(R.string.action_retry)
+ setOnClickListener {
+ if (transition is ChapterTransition.Next) {
+ viewer.activity.requestPreloadNextChapter()
+ } else {
+ viewer.activity.requestPreloadPreviousChapter()
+ }
+ }
+ }
+
+ pagesContainer.addView(textView)
+ pagesContainer.addView(retryBtn)
+ }
+
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt
new file mode 100644
index 000000000..2bb5f1543
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt
@@ -0,0 +1,240 @@
+package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
+
+import android.support.v7.widget.RecyclerView
+import android.support.v7.widget.WebtoonLayoutManager
+import android.view.KeyEvent
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import eu.kanade.tachiyomi.ui.reader.ReaderActivity
+import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
+import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
+import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
+import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer
+import rx.subscriptions.CompositeSubscription
+import timber.log.Timber
+
+/**
+ * Implementation of a [BaseViewer] to display pages with a [RecyclerView].
+ */
+class WebtoonViewer(val activity: ReaderActivity) : BaseViewer {
+
+ /**
+ * Recycler view used by this viewer.
+ */
+ val recycler = WebtoonRecyclerView(activity)
+
+ /**
+ * Frame containing the recycler view.
+ */
+ private val frame = WebtoonFrame(activity)
+
+ /**
+ * Layout manager of the recycler view.
+ */
+ private val layoutManager = WebtoonLayoutManager(activity)
+
+ /**
+ * Adapter of the recycler view.
+ */
+ private val adapter = WebtoonAdapter(this)
+
+ /**
+ * Distance to scroll when the user taps on one side of the recycler view.
+ */
+ private var scrollDistance = activity.resources.displayMetrics.heightPixels * 3 / 4
+
+ /**
+ * Currently active item. It can be a chapter page or a chapter transition.
+ */
+ private var currentPage: Any? = null
+
+ /**
+ * Configuration used by this viewer, like allow taps, or crop image borders.
+ */
+ val config = WebtoonConfig()
+
+ /**
+ * Subscriptions to keep while this viewer is used.
+ */
+ val subscriptions = CompositeSubscription()
+
+ init {
+ recycler.visibility = View.GONE // Don't let the recycler layout yet
+ recycler.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
+ recycler.itemAnimator = null
+ recycler.layoutManager = layoutManager
+ recycler.adapter = adapter
+ recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView?, dx: Int, dy: Int) {
+ val index = layoutManager.findLastEndVisibleItemPosition()
+ val item = adapter.items.getOrNull(index)
+ if (item != null && currentPage != item) {
+ currentPage = item
+ when (item) {
+ is ReaderPage -> onPageSelected(item)
+ is ChapterTransition -> onTransitionSelected(item)
+ }
+ }
+
+ if (dy < 0) {
+ val firstIndex = layoutManager.findFirstVisibleItemPosition()
+ val firstItem = adapter.items.getOrNull(firstIndex)
+ if (firstItem is ChapterTransition.Prev) {
+ activity.requestPreloadPreviousChapter()
+ }
+ }
+ }
+ })
+ recycler.tapListener = { event ->
+ val positionX = event.rawX
+ when {
+ positionX < recycler.width * 0.33 -> if (config.tappingEnabled) scrollUp()
+ positionX > recycler.width * 0.66 -> if (config.tappingEnabled) scrollDown()
+ else -> activity.toggleMenu()
+ }
+ }
+ recycler.longTapListener = { event ->
+ val child = recycler.findChildViewUnder(event.x, event.y)
+ val position = recycler.getChildAdapterPosition(child)
+ val item = adapter.items.getOrNull(position)
+ if (item is ReaderPage) {
+ activity.onPageLongTap(item)
+ }
+ }
+
+ frame.layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
+ frame.addView(recycler)
+ }
+
+ /**
+ * Returns the view this viewer uses.
+ */
+ override fun getView(): View {
+ return frame
+ }
+
+ /**
+ * Destroys this viewer. Called when leaving the reader or swapping viewers.
+ */
+ override fun destroy() {
+ super.destroy()
+ config.unsubscribe()
+ subscriptions.unsubscribe()
+ }
+
+ /**
+ * Called from the ViewPager listener when a [page] is marked as active. It notifies the
+ * activity of the change and requests the preload of the next chapter if this is the last page.
+ */
+ private fun onPageSelected(page: ReaderPage) {
+ val pages = page.chapter.pages!! // Won't be null because it's the loaded chapter
+ Timber.d("onPageSelected: ${page.number}/${pages.size}")
+ activity.onPageSelected(page)
+
+ if (page === pages.last()) {
+ Timber.d("Request preload next chapter because we're at the last page")
+ activity.requestPreloadNextChapter()
+ }
+ }
+
+ /**
+ * Called from the ViewPager listener when a [transition] is marked as active. It request the
+ * preload of the destination chapter of the transition.
+ */
+ private fun onTransitionSelected(transition: ChapterTransition) {
+ Timber.d("onTransitionSelected: $transition")
+ if (transition is ChapterTransition.Prev) {
+ Timber.d("Request preload previous chapter because we're on the transition")
+ activity.requestPreloadPreviousChapter()
+ }
+ }
+
+ /**
+ * Tells this viewer to set the given [chapters] as active.
+ */
+ override fun setChapters(chapters: ViewerChapters) {
+ Timber.d("setChapters")
+ adapter.setChapters(chapters)
+
+ if (recycler.visibility == View.GONE) {
+ Timber.d("Recycler first layout")
+ val pages = chapters.currChapter.pages ?: return
+ moveToPage(pages[chapters.currChapter.requestedPage])
+ recycler.visibility = View.VISIBLE
+ }
+ }
+
+ /**
+ * Tells this viewer to move to the given [page].
+ */
+ override fun moveToPage(page: ReaderPage) {
+ Timber.d("moveToPage")
+ val position = adapter.items.indexOf(page)
+ if (position != -1) {
+ recycler.scrollToPosition(position)
+ } else {
+ Timber.d("Page $page not found in adapter")
+ }
+ }
+
+ /**
+ * Scrolls up by [scrollDistance].
+ */
+ private fun scrollUp() {
+ recycler.smoothScrollBy(0, -scrollDistance)
+ }
+
+ /**
+ * Scrolls down by [scrollDistance].
+ */
+ private fun scrollDown() {
+ recycler.smoothScrollBy(0, scrollDistance)
+ }
+
+ /**
+ * Called from the containing activity when a key [event] is received. It should return true
+ * if the event was handled, false otherwise.
+ */
+ override fun handleKeyEvent(event: KeyEvent): Boolean {
+ val isUp = event.action == KeyEvent.ACTION_UP
+
+ when (event.keyCode) {
+ KeyEvent.KEYCODE_VOLUME_DOWN -> {
+ if (activity.menuVisible) {
+ return false
+ } else if (config.volumeKeysEnabled && isUp) {
+ if (!config.volumeKeysInverted) scrollDown() else scrollUp()
+ }
+ }
+ KeyEvent.KEYCODE_VOLUME_UP -> {
+ if (activity.menuVisible) {
+ return false
+ } else if (config.volumeKeysEnabled && isUp) {
+ if (!config.volumeKeysInverted) scrollUp() else scrollDown()
+ }
+ }
+ KeyEvent.KEYCODE_MENU -> if (isUp) activity.toggleMenu()
+
+ KeyEvent.KEYCODE_DPAD_RIGHT,
+ KeyEvent.KEYCODE_DPAD_UP,
+ KeyEvent.KEYCODE_PAGE_UP -> if (isUp) scrollUp()
+
+ KeyEvent.KEYCODE_DPAD_LEFT,
+ KeyEvent.KEYCODE_DPAD_DOWN,
+ KeyEvent.KEYCODE_PAGE_DOWN -> if (isUp) scrollDown()
+ else -> return false
+ }
+ return true
+ }
+
+ /**
+ * Called from the containing activity when a generic motion [event] is received. It should
+ * return true if the event was handled, false otherwise.
+ */
+ override fun handleGenericMotionEvent(event: MotionEvent): Boolean {
+ return false
+ }
+
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt
index 13dce8177..bcf418761 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/recent_updates/RecentChaptersPresenter.kt
@@ -165,9 +165,8 @@ class RecentChaptersPresenter(
* @param chapters list of chapters
*/
fun deleteChapters(chapters: List) {
- Observable.from(chapters)
- .doOnNext { deleteChapter(it) }
- .toList()
+ Observable.just(chapters)
+ .doOnNext { deleteChaptersInternal(it) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ ->
@@ -184,16 +183,23 @@ class RecentChaptersPresenter(
}
/**
- * Delete selected chapter
+ * Delete selected chapters
*
- * @param item chapter that is selected
+ * @param items chapters selected
*/
- private fun deleteChapter(item: RecentChapterItem) {
- val source = sourceManager.get(item.manga.source) ?: return
- downloadManager.queue.remove(item.chapter)
- downloadManager.deleteChapter(item.chapter, item.manga, source)
- item.status = Download.NOT_DOWNLOADED
- item.download = null
+ private fun deleteChaptersInternal(chapterItems: List) {
+ val itemsByManga = chapterItems.groupBy { it.manga.id }
+ for ((_, items) in itemsByManga) {
+ val manga = items.first().manga
+ val source = sourceManager.get(manga.source) ?: continue
+ val chapters = items.map { it.chapter }
+
+ downloadManager.deleteChapters(chapters, manga, source)
+ items.forEach {
+ it.status = Download.NOT_DOWNLOADED
+ it.download = null
+ }
+ }
}
}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt
index ae9f739f6..66dd44597 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsReaderController.kt
@@ -54,14 +54,6 @@ class SettingsReaderController : SettingsController() {
defaultValue = "0"
summary = "%s"
}
- intListPreference {
- key = Keys.imageDecoder
- titleRes = R.string.pref_image_decoder
- entries = arrayOf("Image", "Rapid", "Skia")
- entryValues = arrayOf("0", "1", "2")
- defaultValue = "0"
- summary = "%s"
- }
intListPreference {
key = Keys.doubleTapAnimationSpeed
titleRes = R.string.pref_double_tap_anim_speed
diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt
index 75619dd78..d89b3ce5a 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt
@@ -11,6 +11,7 @@ import android.content.pm.PackageManager
import android.content.res.Resources
import android.net.ConnectivityManager
import android.os.PowerManager
+import android.support.annotation.AttrRes
import android.support.annotation.StringRes
import android.support.v4.app.NotificationCompat
import android.support.v4.content.ContextCompat
@@ -79,7 +80,7 @@ fun Context.hasPermission(permission: String)
*
* @param resource the attribute.
*/
-fun Context.getResourceColor(@StringRes resource: Int): Int {
+fun Context.getResourceColor(@AttrRes resource: Int): Int {
val typedArray = obtainStyledAttributes(intArrayOf(resource))
val attrValue = typedArray.getColor(0, 0)
typedArray.recycle()
@@ -161,4 +162,4 @@ fun Context.isServiceRunning(serviceClass: Class<*>): Boolean {
@Suppress("DEPRECATION")
return manager.getRunningServices(Integer.MAX_VALUE)
.any { className == it.service.className }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt
index eb90b38a1..edff38614 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/util/DiskUtil.kt
@@ -8,47 +8,9 @@ import android.os.Environment
import android.support.v4.content.ContextCompat
import android.support.v4.os.EnvironmentCompat
import java.io.File
-import java.io.InputStream
-import java.net.URLConnection
object DiskUtil {
- fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
- val contentType = try {
- URLConnection.guessContentTypeFromName(name)
- } catch (e: Exception) {
- null
- } ?: openStream?.let { findImageMime(it) }
-
- return contentType?.startsWith("image/") ?: false
- }
-
- fun findImageMime(openStream: () -> InputStream): String? {
- try {
- openStream().buffered().use {
- val bytes = ByteArray(8)
- it.mark(bytes.size)
- val length = it.read(bytes, 0, bytes.size)
- it.reset()
- if (length == -1)
- return null
- if (bytes[0] == 'G'.toByte() && bytes[1] == 'I'.toByte() && bytes[2] == 'F'.toByte() && bytes[3] == '8'.toByte()) {
- return "image/gif"
- } else if (bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() && bytes[2] == 0x4E.toByte()
- && bytes[3] == 0x47.toByte() && bytes[4] == 0x0D.toByte() && bytes[5] == 0x0A.toByte()
- && bytes[6] == 0x1A.toByte() && bytes[7] == 0x0A.toByte()) {
- return "image/png"
- } else if (bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte()) {
- return "image/jpeg"
- } else if (bytes[0] == 'W'.toByte() && bytes[1] == 'E'.toByte() && bytes[2] == 'B'.toByte() && bytes[3] == 'P'.toByte()) {
- return "image/webp"
- }
- }
- } catch(e: Exception) {
- }
- return null
- }
-
fun hashKeyForDisk(key: String): String {
return Hash.md5(key)
}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/EpubFile.kt b/app/src/main/java/eu/kanade/tachiyomi/util/EpubFile.kt
new file mode 100644
index 000000000..f1c81b5b8
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/util/EpubFile.kt
@@ -0,0 +1,117 @@
+package eu.kanade.tachiyomi.util
+
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Document
+import java.io.Closeable
+import java.io.File
+import java.io.InputStream
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+
+/**
+ * Wrapper over ZipFile to load files in epub format.
+ */
+class EpubFile(file: File) : Closeable {
+
+ /**
+ * Zip file of this epub.
+ */
+ private val zip = ZipFile(file)
+
+ /**
+ * Closes the underlying zip file.
+ */
+ override fun close() {
+ zip.close()
+ }
+
+ /**
+ * Returns an input stream for reading the contents of the specified zip file entry.
+ */
+ fun getInputStream(entry: ZipEntry): InputStream {
+ return zip.getInputStream(entry)
+ }
+
+ /**
+ * Returns the zip file entry for the specified name, or null if not found.
+ */
+ fun getEntry(name: String): ZipEntry? {
+ return zip.getEntry(name)
+ }
+
+ /**
+ * Returns the path of all the images found in the epub file.
+ */
+ fun getImagesFromPages(): List {
+ val allEntries = zip.entries().toList()
+ val ref = getPackageHref()
+ val doc = getPackageDocument(ref)
+ val pages = getPagesFromDocument(doc)
+ val hrefs = getHrefMap(ref, allEntries.map { it.name })
+ return getImagesFromPages(pages, hrefs)
+ }
+
+ /**
+ * Returns the path to the package document.
+ */
+ private fun getPackageHref(): String {
+ val meta = zip.getEntry("META-INF/container.xml")
+ if (meta != null) {
+ val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") }
+ val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
+ if (path != null) {
+ return path
+ }
+ }
+ return "OEBPS/content.opf"
+ }
+
+ /**
+ * Returns the package document where all the files are listed.
+ */
+ private fun getPackageDocument(ref: String): Document {
+ val entry = zip.getEntry(ref)
+ return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
+ }
+
+ /**
+ * Returns all the pages from the epub.
+ */
+ private fun getPagesFromDocument(document: Document): List {
+ val pages = document.select("manifest > item")
+ .filter { "application/xhtml+xml" == it.attr("media-type") }
+ .associateBy { it.attr("id") }
+
+ val spine = document.select("spine > itemref").map { it.attr("idref") }
+ return spine.mapNotNull { pages[it] }.map { it.attr("href") }
+ }
+
+ /**
+ * Returns all the images contained in every page from the epub.
+ */
+ private fun getImagesFromPages(pages: List, hrefs: Map): List {
+ return pages.map { page ->
+ val entry = zip.getEntry(hrefs[page])
+ val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
+ document.getElementsByTag("img").mapNotNull { hrefs[it.attr("src")] }
+ }.flatten()
+ }
+
+ /**
+ * Returns a map with a relative url as key and abolute url as path.
+ */
+ private fun getHrefMap(packageHref: String, entries: List): Map {
+ val lastSlashPos = packageHref.lastIndexOf('/')
+ if (lastSlashPos < 0) {
+ return entries.associateBy { it }
+ }
+ return entries.associateBy { entry ->
+ if (entry.isNotBlank() && entry.length > lastSlashPos) {
+ entry.substring(lastSlashPos + 1)
+ } else {
+ entry
+ }
+ }
+ }
+
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ImageUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ImageUtil.kt
new file mode 100644
index 000000000..dd9721957
--- /dev/null
+++ b/app/src/main/java/eu/kanade/tachiyomi/util/ImageUtil.kt
@@ -0,0 +1,78 @@
+package eu.kanade.tachiyomi.util
+
+import java.io.InputStream
+import java.net.URLConnection
+
+object ImageUtil {
+
+ fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
+ try {
+ val guessedMime = URLConnection.guessContentTypeFromName(name)
+ if (guessedMime.startsWith("image/")) {
+ return true
+ }
+ } catch (e: Exception) {
+ /* Ignore error */
+ }
+
+ return openStream?.let { findImageType(it) } != null
+ }
+
+ fun findImageType(openStream: () -> InputStream): ImageType? {
+ return openStream().use { findImageType(it) }
+ }
+
+ fun findImageType(stream: InputStream): ImageType? {
+ try {
+ val bytes = ByteArray(8)
+
+ val length = if (stream.markSupported()) {
+ stream.mark(bytes.size)
+ stream.read(bytes, 0, bytes.size).also { stream.reset() }
+ } else {
+ stream.read(bytes, 0, bytes.size)
+ }
+
+ if (length == -1)
+ return null
+
+ if (bytes.compareWith(charByteArrayOf(0xFF, 0xD8, 0xFF))) {
+ return ImageType.JPG
+ }
+ if (bytes.compareWith(charByteArrayOf(0x89, 0x50, 0x4E, 0x47))) {
+ return ImageType.PNG
+ }
+ if (bytes.compareWith("GIF8".toByteArray())) {
+ return ImageType.GIF
+ }
+ if (bytes.compareWith("RIFF".toByteArray())) {
+ return ImageType.WEBP
+ }
+ } catch(e: Exception) {
+ }
+ return null
+ }
+
+ private fun ByteArray.compareWith(magic: ByteArray): Boolean {
+ for (i in 0 until magic.size) {
+ if (this[i] != magic[i]) return false
+ }
+ return true
+ }
+
+ private fun charByteArrayOf(vararg bytes: Int): ByteArray {
+ return ByteArray(bytes.size).apply {
+ for (i in 0 until bytes.size) {
+ set(i, bytes[i].toByte())
+ }
+ }
+ }
+
+ enum class ImageType(val mime: String, val extension: String) {
+ JPG("image/jpeg", "jpg"),
+ PNG("image/png", "png"),
+ GIF("image/gif", "gif"),
+ WEBP("image/webp", "webp")
+ }
+
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/RarContentProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/util/RarContentProvider.kt
deleted file mode 100644
index 12ad9706f..000000000
--- a/app/src/main/java/eu/kanade/tachiyomi/util/RarContentProvider.kt
+++ /dev/null
@@ -1,73 +0,0 @@
-package eu.kanade.tachiyomi.util
-
-import android.content.ContentProvider
-import android.content.ContentValues
-import android.content.res.AssetFileDescriptor
-import android.database.Cursor
-import android.net.Uri
-import android.os.ParcelFileDescriptor
-import eu.kanade.tachiyomi.BuildConfig
-import junrar.Archive
-import java.io.File
-import java.io.IOException
-import java.net.URLConnection
-import java.util.concurrent.Executors
-
-class RarContentProvider : ContentProvider() {
-
- private val pool by lazy { Executors.newCachedThreadPool() }
-
- companion object {
- const val PROVIDER = "${BuildConfig.APPLICATION_ID}.rar-provider"
- }
-
- override fun onCreate(): Boolean {
- return true
- }
-
- override fun getType(uri: Uri): String? {
- return URLConnection.guessContentTypeFromName(uri.toString())
- }
-
- override fun openAssetFile(uri: Uri, mode: String): AssetFileDescriptor? {
- try {
- val pipe = ParcelFileDescriptor.createPipe()
- pool.execute {
- try {
- val (rar, file) = uri.toString()
- .substringAfter("content://$PROVIDER")
- .split("!-/", limit = 2)
-
- Archive(File(rar)).use { archive ->
- val fileHeader = archive.fileHeaders.first { it.fileNameString == file }
-
- ParcelFileDescriptor.AutoCloseOutputStream(pipe[1]).use { output ->
- archive.extractFile(fileHeader, output)
- }
- }
- } catch (e: Exception) {
- // Ignore
- }
- }
- return AssetFileDescriptor(pipe[0], 0, -1)
- } catch (e: IOException) {
- return null
- }
- }
-
- override fun query(p0: Uri?, p1: Array?, p2: String?, p3: Array?, p4: String?): Cursor? {
- return null
- }
-
- override fun insert(p0: Uri?, p1: ContentValues?): Uri {
- throw UnsupportedOperationException("not implemented")
- }
-
- override fun update(p0: Uri?, p1: ContentValues?, p2: String?, p3: Array?): Int {
- throw UnsupportedOperationException("not implemented")
- }
-
- override fun delete(p0: Uri?, p1: String?, p2: Array?): Int {
- throw UnsupportedOperationException("not implemented")
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/RxExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/RxExtensions.kt
index 1f4933552..8a0c965ff 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/util/RxExtensions.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/util/RxExtensions.kt
@@ -10,4 +10,8 @@ operator fun CompositeSubscription.plusAssign(subscription: Subscription) = add(
fun Observable.combineLatest(o2: Observable, combineFn: (T, U) -> R): Observable {
return Observable.combineLatest(this, o2, combineFn)
-}
\ No newline at end of file
+}
+
+fun Subscription.addTo(subscriptions: CompositeSubscription) {
+ subscriptions.add(this)
+}
diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ZipContentProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ZipContentProvider.kt
deleted file mode 100644
index 737007720..000000000
--- a/app/src/main/java/eu/kanade/tachiyomi/util/ZipContentProvider.kt
+++ /dev/null
@@ -1,69 +0,0 @@
-package eu.kanade.tachiyomi.util
-
-import android.content.ContentProvider
-import android.content.ContentValues
-import android.content.res.AssetFileDescriptor
-import android.database.Cursor
-import android.net.Uri
-import android.os.ParcelFileDescriptor
-import eu.kanade.tachiyomi.BuildConfig
-import java.io.IOException
-import java.net.URL
-import java.net.URLConnection
-import java.util.concurrent.Executors
-
-class ZipContentProvider : ContentProvider() {
-
- private val pool by lazy { Executors.newCachedThreadPool() }
-
- companion object {
- const val PROVIDER = "${BuildConfig.APPLICATION_ID}.zip-provider"
- }
-
- override fun onCreate(): Boolean {
- return true
- }
-
- override fun getType(uri: Uri): String? {
- return URLConnection.guessContentTypeFromName(uri.toString())
- }
-
- override fun openAssetFile(uri: Uri, mode: String): AssetFileDescriptor? {
- try {
- val url = "jar:file://" + uri.toString().substringAfter("content://$PROVIDER")
- val input = URL(url).openStream()
- val pipe = ParcelFileDescriptor.createPipe()
- pool.execute {
- try {
- val output = ParcelFileDescriptor.AutoCloseOutputStream(pipe[1])
- input.use {
- output.use {
- input.copyTo(output)
- }
- }
- } catch (e: IOException) {
- // Ignore
- }
- }
- return AssetFileDescriptor(pipe[0], 0, -1)
- } catch (e: IOException) {
- return null
- }
- }
-
- override fun query(p0: Uri?, p1: Array?, p2: String?, p3: Array?, p4: String?): Cursor? {
- return null
- }
-
- override fun insert(p0: Uri?, p1: ContentValues?): Uri {
- throw UnsupportedOperationException("not implemented")
- }
-
- override fun update(p0: Uri?, p1: ContentValues?, p2: String?, p3: Array?): Int {
- throw UnsupportedOperationException("not implemented")
- }
-
- override fun delete(p0: Uri?, p1: String?, p2: Array?): Int {
- throw UnsupportedOperationException("not implemented")
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/ViewPagerAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/ViewPagerAdapter.kt
index c2c21a66b..3effd64ec 100644
--- a/app/src/main/java/eu/kanade/tachiyomi/widget/ViewPagerAdapter.kt
+++ b/app/src/main/java/eu/kanade/tachiyomi/widget/ViewPagerAdapter.kt
@@ -27,4 +27,8 @@ abstract class ViewPagerAdapter : PagerAdapter() {
return view === obj
}
-}
\ No newline at end of file
+ interface PositionableView {
+ val item: Any
+ }
+
+}
diff --git a/app/src/main/res/drawable/ic_image_black_24dp.xml b/app/src/main/res/drawable/ic_image_black_24dp.xml
new file mode 100644
index 000000000..b2018595e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_image_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/layout-land/reader_color_filter_sheet.xml b/app/src/main/res/layout-land/reader_color_filter_sheet.xml
new file mode 100644
index 000000000..ba4d45e40
--- /dev/null
+++ b/app/src/main/res/layout-land/reader_color_filter_sheet.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/reader_activity.xml b/app/src/main/res/layout/reader_activity.xml
index 88e479e51..777bdaa78 100644
--- a/app/src/main/res/layout/reader_activity.xml
+++ b/app/src/main/res/layout/reader_activity.xml
@@ -11,17 +11,18 @@
android:layout_height="match_parent">
+ android:layout_width="56dp"
+ android:layout_height="56dp"
+ android:layout_gravity="center"
+ android:visibility="gone"
+ tools:visibility="visible"/>
@@ -47,8 +49,7 @@
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?colorPrimary"
- android:elevation="4dp"
- android:theme="?attr/actionBarTheme"/>
+ android:elevation="4dp" />
+ android:textSize="15sp"
+ android:clickable="true"
+ tools:text="1"/>
-
+ android:layout_height="match_parent"
+ android:layout_weight="1" />
+ android:textSize="15sp"
+ android:clickable="true"
+ tools:text="15"/>
@@ -113,4 +117,4 @@
android:layout_height="match_parent"
android:visibility="gone"/>
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout/reader_color_filter.xml b/app/src/main/res/layout/reader_color_filter.xml
new file mode 100644
index 000000000..736ee590e
--- /dev/null
+++ b/app/src/main/res/layout/reader_color_filter.xml
@@ -0,0 +1,205 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/reader_color_filter_sheet.xml b/app/src/main/res/layout/reader_color_filter_sheet.xml
new file mode 100644
index 000000000..618a8a77f
--- /dev/null
+++ b/app/src/main/res/layout/reader_color_filter_sheet.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/reader_custom_filter_dialog.xml b/app/src/main/res/layout/reader_custom_filter_dialog.xml
deleted file mode 100644
index 0f5483dea..000000000
--- a/app/src/main/res/layout/reader_custom_filter_dialog.xml
+++ /dev/null
@@ -1,263 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/reader_page_decode_error.xml b/app/src/main/res/layout/reader_page_decode_error.xml
deleted file mode 100644
index e623788c5..000000000
--- a/app/src/main/res/layout/reader_page_decode_error.xml
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/reader_page_sheet.xml b/app/src/main/res/layout/reader_page_sheet.xml
new file mode 100644
index 000000000..446f00d61
--- /dev/null
+++ b/app/src/main/res/layout/reader_page_sheet.xml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/reader_pager_item.xml b/app/src/main/res/layout/reader_pager_item.xml
deleted file mode 100644
index 73b1d8949..000000000
--- a/app/src/main/res/layout/reader_pager_item.xml
+++ /dev/null
@@ -1,45 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/reader_settings_dialog.xml b/app/src/main/res/layout/reader_settings_dialog.xml
deleted file mode 100644
index ea9793236..000000000
--- a/app/src/main/res/layout/reader_settings_dialog.xml
+++ /dev/null
@@ -1,186 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/reader_settings_sheet.xml b/app/src/main/res/layout/reader_settings_sheet.xml
new file mode 100644
index 000000000..85ab2c975
--- /dev/null
+++ b/app/src/main/res/layout/reader_settings_sheet.xml
@@ -0,0 +1,247 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/reader_webtoon_item.xml b/app/src/main/res/layout/reader_webtoon_item.xml
deleted file mode 100644
index 9bb7826b7..000000000
--- a/app/src/main/res/layout/reader_webtoon_item.xml
+++ /dev/null
@@ -1,55 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/values-v21/themes.xml b/app/src/main/res/values-v21/themes.xml
index 346e9027e..84ccab60f 100644
--- a/app/src/main/res/values-v21/themes.xml
+++ b/app/src/main/res/values-v21/themes.xml
@@ -33,10 +33,16 @@
-
-
\ No newline at end of file
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 376e00dbb..eab6d8874 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -24,7 +24,7 @@
@color/md_white_1000
@color/md_blue_A400_38
- @color/md_black_1000
+ @color/md_black_1000_54
#3399FF
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index be1ffe249..73e735cf7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -425,6 +425,14 @@
Update last chapter read in enabled services to %1$d?
Do you want to set this image as the cover?
Viewer for this series
+ Finished: %1$s
+ Current: %1$s
+ Next: %1$s
+ Previous: %1$s
+ There\'s no next chapter
+ There\'s no previous chapter
+ Loading pages…
+ Failed to load pages: %1$s
%1$s - Ch.%2$s
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 7e3c86eb5..90fdb2696 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -104,12 +104,23 @@
-
-
+
+
+
+
@@ -122,4 +133,4 @@
-
\ No newline at end of file
+
diff --git a/build.gradle b/build.gradle
index edc3d96c6..778577bc6 100644
--- a/build.gradle
+++ b/build.gradle
@@ -7,7 +7,7 @@ buildscript {
google()
}
dependencies {
- classpath 'com.android.tools.build:gradle:3.1.3'
+ classpath 'com.android.tools.build:gradle:3.1.4'
classpath 'com.github.ben-manes:gradle-versions-plugin:0.17.0'
classpath 'com.github.zellius:android-shortcut-gradle-plugin:0.1.2'
classpath 'com.google.gms:google-services:3.2.0'