/******************************************************************************* * This file is part of RedReader. * * RedReader is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * RedReader is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with RedReader. If not, see <http://www.gnu.org/licenses/>. ******************************************************************************/ package org.quantumbadger.redreader.views.imageview; import android.content.Context; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.util.Log; import org.quantumbadger.redreader.common.MutableFloatPoint2D; import org.quantumbadger.redreader.common.PrefsUtility; import org.quantumbadger.redreader.common.UIThreadRepeatingTimer; import org.quantumbadger.redreader.common.collections.Stack; import org.quantumbadger.redreader.views.glview.Refreshable; import org.quantumbadger.redreader.views.glview.displaylist.RRGLDisplayList; import org.quantumbadger.redreader.views.glview.displaylist.RRGLDisplayListRenderer; import org.quantumbadger.redreader.views.glview.displaylist.RRGLRenderableGroup; import org.quantumbadger.redreader.views.glview.displaylist.RRGLRenderableScale; import org.quantumbadger.redreader.views.glview.displaylist.RRGLRenderableTexturedQuad; import org.quantumbadger.redreader.views.glview.displaylist.RRGLRenderableTranslation; import org.quantumbadger.redreader.views.glview.program.RRGLContext; import org.quantumbadger.redreader.views.glview.program.RRGLTexture; import java.util.Arrays; public class ImageViewDisplayListManager implements RRGLDisplayListRenderer.DisplayListManager, UIThreadRepeatingTimer.Listener, ImageViewTileLoader.Listener { public interface Listener extends BasicGestureHandler.Listener { void onImageViewDLMOutOfMemory(); void onImageViewDLMException(Throwable t); } private static final long TAP_MAX_DURATION_MS = 225; private static final long DOUBLE_TAP_MAX_GAP_DURATION_MS = 275; private final Listener mListener; private RRGLRenderableTranslation mOverallTranslation; private RRGLRenderableScale mOverallScale; private final ImageTileSource mImageTileSource; private final int mHTileCount, mVTileCount; private final int mTileSize; private RRGLTexture mNotLoadedTexture; private int mResolutionX; private int mResolutionY; private final MultiScaleTileManager[][] mTileLoaders; private final RRGLRenderableTexturedQuad[][] mTiles; private boolean[][] mTileVisibility; private boolean[][] mTileLoaded; private int mLastSampleSize = 1; private Refreshable mRefreshable; private enum TouchState { ONE_FINGER_DOWN, ONE_FINGER_DRAG, TWO_FINGER_PINCH, DOUBLE_TAP_WAIT_NO_FINGERS_DOWN, DOUBLE_TAP_ONE_FINGER_DOWN, DOUBLE_TAP_ONE_FINGER_DRAG } private final CoordinateHelper mCoordinateHelper = new CoordinateHelper(); private BoundsHelper mBoundsHelper = null; private TouchState mCurrentTouchState = null; private FingerTracker.Finger mDragFinger; private FingerTracker.Finger mPinchFinger1, mPinchFinger2; private final Stack<FingerTracker.Finger> mSpareFingers = new Stack<>(8); private final UIThreadRepeatingTimer mDoubleTapGapTimer = new UIThreadRepeatingTimer(50, this); private long mFirstTapReleaseTime = -1; private ImageViewScaleAnimation mScaleAnimation = null; private ImageViewScrollbars mScrollbars; private float mScreenDensity = 1; private final int mLoadingCheckerboardDarkCol; private final int mLoadingCheckerboardLightCol; public ImageViewDisplayListManager(final Context context, ImageTileSource imageTileSource, Listener listener) { mImageTileSource = imageTileSource; mListener = listener; mHTileCount = mImageTileSource.getHTileCount(); mVTileCount = mImageTileSource.getVTileCount(); mTileSize = mImageTileSource.getTileSize(); mTiles = new RRGLRenderableTexturedQuad[mHTileCount][mVTileCount]; mTileLoaders = new MultiScaleTileManager[mHTileCount][mVTileCount]; final ImageViewTileLoaderThread thread = new ImageViewTileLoaderThread(); for(int x = 0; x < mHTileCount; x++) { for(int y = 0; y < mVTileCount; y++) { mTileLoaders[x][y] = new MultiScaleTileManager(imageTileSource, thread, x, y, this); } } if(PrefsUtility.isNightMode(context)) { mLoadingCheckerboardDarkCol = Color.rgb(70, 70, 70); mLoadingCheckerboardLightCol = Color.rgb(110, 110, 110); } else { mLoadingCheckerboardDarkCol = Color.rgb(150, 150, 150); mLoadingCheckerboardLightCol = Color.WHITE; } } @Override public synchronized void onGLSceneCreate(RRGLDisplayList scene, RRGLContext glContext, Refreshable refreshable) { mTileVisibility = new boolean[mHTileCount][mVTileCount]; mTileLoaded = new boolean[mHTileCount][mVTileCount]; mRefreshable = refreshable; mScreenDensity = glContext.getScreenDensity(); final Bitmap notLoadedBitmap = Bitmap.createBitmap(256, 256, Bitmap.Config.ARGB_8888); final Canvas notLoadedCanvas = new Canvas(notLoadedBitmap); final Paint lightPaint = new Paint(); final Paint darkPaint = new Paint(); lightPaint.setColor(mLoadingCheckerboardLightCol); darkPaint.setColor(mLoadingCheckerboardDarkCol); for(int x = 0; x < 4; x++) { for(int y = 0; y < 4; y++) { final Paint paint = ((x ^ y) & 1) == 0 ? lightPaint : darkPaint; notLoadedCanvas.drawRect(x * 64, y * 64, (x + 1) * 64, (y + 1) * 64, paint); } } mNotLoadedTexture = new RRGLTexture(glContext, notLoadedBitmap); final RRGLRenderableGroup group = new RRGLRenderableGroup(); mOverallScale = new RRGLRenderableScale(group); mOverallTranslation = new RRGLRenderableTranslation(mOverallScale); scene.add(mOverallTranslation); for(int x = 0; x < mHTileCount; x++) { for(int y = 0; y < mVTileCount; y++) { final RRGLRenderableTexturedQuad quad = new RRGLRenderableTexturedQuad(glContext, mNotLoadedTexture); mTiles[x][y] = quad; final RRGLRenderableTranslation translation = new RRGLRenderableTranslation(quad); translation.setPosition(x, y); final RRGLRenderableScale scale = new RRGLRenderableScale(translation); scale.setScale(mTileSize, mTileSize); group.add(scale); } } mScrollbars = new ImageViewScrollbars( glContext, mCoordinateHelper, mImageTileSource.getWidth(), mImageTileSource.getHeight() ); scene.add(mScrollbars); } @Override public synchronized void onGLSceneResolutionChange(RRGLDisplayList scene, RRGLContext context, int width, int height) { mResolutionX = width; mResolutionY = height; final boolean setInitialScale = (mBoundsHelper == null); mBoundsHelper = new BoundsHelper( width, height, mImageTileSource.getWidth(), mImageTileSource.getHeight(), mCoordinateHelper); if(setInitialScale) { mBoundsHelper.applyMinScale(); } mScrollbars.setResolution(width, height); mScrollbars.showBars(); } @Override public synchronized boolean onGLSceneUpdate(RRGLDisplayList scene, RRGLContext context) { if(mScaleAnimation != null) { if(!mScaleAnimation.onStep()) { mScaleAnimation = null; } } if(mBoundsHelper != null) { mBoundsHelper.applyBounds(); } final MutableFloatPoint2D positionOffset = mCoordinateHelper.getPositionOffset(); final float scale = mCoordinateHelper.getScale(); mOverallTranslation.setPosition(positionOffset); mOverallScale.setScale(scale, scale); mScrollbars.update(); final int sampleSize = pickSampleSize(); if(mLastSampleSize != sampleSize) { for(final boolean[] arr : mTileLoaded) { Arrays.fill(arr, false); } mLastSampleSize = sampleSize; } final float firstVisiblePixelX = -positionOffset.x / scale; final float firstVisiblePixelY = -positionOffset.y / scale; final int firstVisibleTileX = (int) Math.floor(firstVisiblePixelX / mTileSize); final int firstVisibleTileY = (int) Math.floor(firstVisiblePixelY / mTileSize); final float lastVisiblePixelX = firstVisiblePixelX + (float)mResolutionX / scale; final float lastVisiblePixelY = firstVisiblePixelY + (float)mResolutionY / scale; final int lastVisibleTileX = (int) Math.ceil(lastVisiblePixelX / mTileSize); final int lastVisibleTileY = (int) Math.ceil(lastVisiblePixelY / mTileSize); final int desiredScaleIndex = MultiScaleTileManager.sampleSizeToScaleIndex(sampleSize); for(int x = 0; x < mHTileCount; x++) { for(int y = 0; y < mVTileCount; y++) { final boolean isTileVisible = x >= firstVisibleTileX && y >= firstVisibleTileY && x <= lastVisibleTileX && y <= lastVisibleTileY; final boolean isTileWanted = x >= firstVisibleTileX - 1 && y >= firstVisibleTileY - 1 && x <= lastVisibleTileX + 1 && y <= lastVisibleTileY + 1; if(isTileWanted && !mTileLoaded[x][y]) { mTileLoaders[x][y].markAsWanted(desiredScaleIndex); } else { mTileLoaders[x][y].markAsUnwanted(); } if(isTileVisible != mTileVisibility[x][y] || !mTileLoaded[x][y]) { if(isTileVisible && !mTileLoaded[x][y]) { final Bitmap tile = mTileLoaders[x][y].getAtDesiredScale(); if(tile != null) { try { final RRGLTexture texture = new RRGLTexture(context, tile); mTiles[x][y].setTexture(texture); texture.releaseReference(); mTileLoaded[x][y] = true; tile.recycle(); } catch(Exception e) { Log.e("ImageViewDisplayListMan", "Exception when creating texture", e); } } } else if(!isTileWanted) { mTiles[x][y].setTexture(mNotLoadedTexture); } mTileVisibility[x][y] = isTileVisible; } } } if(mScaleAnimation != null) { mScrollbars.showBars(); } return mScaleAnimation != null; } @Override public void onUIAttach() {} @Override public void onUIDetach() { mImageTileSource.dispose(); } @Override public synchronized void onFingerDown(FingerTracker.Finger finger) { if(mScrollbars == null) { return; } mScaleAnimation = null; mScrollbars.showBars(); if(mCurrentTouchState == null) { mCurrentTouchState = TouchState.ONE_FINGER_DOWN; mDragFinger = finger; } else { switch(mCurrentTouchState) { case DOUBLE_TAP_WAIT_NO_FINGERS_DOWN: mCurrentTouchState = TouchState.DOUBLE_TAP_ONE_FINGER_DOWN; mDragFinger = finger; mDoubleTapGapTimer.stopTimer(); break; case ONE_FINGER_DRAG: mListener.onHorizontalSwipeEnd(); // Deliberate fallthrough case ONE_FINGER_DOWN: case DOUBLE_TAP_ONE_FINGER_DOWN: case DOUBLE_TAP_ONE_FINGER_DRAG: mCurrentTouchState = TouchState.TWO_FINGER_PINCH; mPinchFinger1 = mDragFinger; mPinchFinger2 = finger; mDragFinger = null; break; default: mSpareFingers.push(finger); break; } } } private final MutableFloatPoint2D mTmpPoint1_onFingersMoved = new MutableFloatPoint2D(); private final MutableFloatPoint2D mTmpPoint2_onFingersMoved = new MutableFloatPoint2D(); @Override public synchronized void onFingersMoved() { if(mCurrentTouchState == null) { return; } if(mScrollbars == null) { return; } mScaleAnimation = null; mScrollbars.showBars(); switch(mCurrentTouchState) { case DOUBLE_TAP_ONE_FINGER_DOWN: { if(mDragFinger.mTotalPosDifference.distanceSquared() >= 400f * mScreenDensity * mScreenDensity) { mCurrentTouchState = TouchState.DOUBLE_TAP_ONE_FINGER_DRAG; } break; } case DOUBLE_TAP_ONE_FINGER_DRAG: { final MutableFloatPoint2D screenCentre = mTmpPoint1_onFingersMoved; screenCentre.set(mResolutionX / 2, mResolutionY / 2); mCoordinateHelper.scaleAboutScreenPoint( screenCentre, (float)Math.pow(1.01, mDragFinger.mPosDifference.y / mScreenDensity) ); break; } case ONE_FINGER_DOWN: { if(mDragFinger.mTotalPosDifference.distanceSquared() >= 100f * mScreenDensity * mScreenDensity) { mCurrentTouchState = TouchState.ONE_FINGER_DRAG; } // Deliberate fall-through } case ONE_FINGER_DRAG: if(mBoundsHelper.isMinScale()) { mListener.onHorizontalSwipe(mDragFinger.mTotalPosDifference.x); } else { mCoordinateHelper.translateScreen(mDragFinger.mLastPos, mDragFinger.mCurrentPos); } break; case TWO_FINGER_PINCH: { final double oldDistance = mPinchFinger1.mLastPos.euclideanDistanceTo(mPinchFinger2.mLastPos); final double newDistance = mPinchFinger1.mCurrentPos.euclideanDistanceTo(mPinchFinger2.mCurrentPos); final MutableFloatPoint2D oldCentre = mTmpPoint1_onFingersMoved; mPinchFinger1.mLastPos.add(mPinchFinger2.mLastPos, oldCentre); oldCentre.scale(0.5); final MutableFloatPoint2D newCentre = mTmpPoint2_onFingersMoved; mPinchFinger1.mCurrentPos.add(mPinchFinger2.mCurrentPos, newCentre); newCentre.scale(0.5); final float scaleDifference = (float) (newDistance / oldDistance); mCoordinateHelper.scaleAboutScreenPoint(newCentre, scaleDifference); mCoordinateHelper.translateScreen(oldCentre, newCentre); break; } } } @Override public synchronized void onFingerUp(FingerTracker.Finger finger) { if(mScrollbars == null) { return; } mScaleAnimation = null; mScrollbars.showBars(); if(mSpareFingers.remove(finger)) { return; } if(mCurrentTouchState == null) { return; } switch(mCurrentTouchState) { case DOUBLE_TAP_ONE_FINGER_DOWN: if(finger.mDownDuration < TAP_MAX_DURATION_MS) { onDoubleTap(finger.mCurrentPos); } mCurrentTouchState = null; mDragFinger = null; break; case ONE_FINGER_DOWN: if(finger.mDownDuration < TAP_MAX_DURATION_MS) { // Maybe a single tap mDoubleTapGapTimer.startTimer(); mCurrentTouchState = TouchState.DOUBLE_TAP_WAIT_NO_FINGERS_DOWN; mFirstTapReleaseTime = System.currentTimeMillis(); } else { mCurrentTouchState = null; } mDragFinger = null; break; case ONE_FINGER_DRAG: mListener.onHorizontalSwipeEnd(); // Deliberate fallthrough case DOUBLE_TAP_ONE_FINGER_DRAG: if(mSpareFingers.isEmpty()) { mCurrentTouchState = null; mDragFinger = null; } else { mDragFinger = mSpareFingers.pop(); } break; case TWO_FINGER_PINCH: if(mSpareFingers.isEmpty()) { mCurrentTouchState = TouchState.ONE_FINGER_DRAG; mDragFinger = (mPinchFinger1 == finger) ? mPinchFinger2 : mPinchFinger1; mPinchFinger1 = null; mPinchFinger2 = null; } else { if(mPinchFinger1 == finger) { mPinchFinger1 = mSpareFingers.pop(); } else { mPinchFinger2 = mSpareFingers.pop(); } } break; } } private void onDoubleTap(MutableFloatPoint2D position) { final float minScale = mBoundsHelper.getMinScale(); final float currentScale = mCoordinateHelper.getScale(); float targetScale; if(currentScale > minScale * 1.01) { targetScale = minScale; } else { targetScale = Math.max( (float)mResolutionX / (float)mImageTileSource.getWidth(), (float)mResolutionY / (float)mImageTileSource.getHeight() ); if(Math.abs((targetScale / currentScale) - 1.0) < 0.05) { targetScale = currentScale * 3; } } mScaleAnimation = new ImageViewScaleAnimation(targetScale, mCoordinateHelper, 15, position); } @Override public void onUIThreadRepeatingTimer(UIThreadRepeatingTimer timer) { if(mCurrentTouchState == TouchState.DOUBLE_TAP_WAIT_NO_FINGERS_DOWN) { if(System.currentTimeMillis() - mFirstTapReleaseTime > DOUBLE_TAP_MAX_GAP_DURATION_MS) { mListener.onSingleTap(); mCurrentTouchState = null; mDoubleTapGapTimer.stopTimer(); } } else { mDoubleTapGapTimer.stopTimer(); } } private int pickSampleSize() { int result = 1; while(result <= MultiScaleTileManager.MAX_SAMPLE_SIZE && (1.0 / (result * 2)) > mCoordinateHelper.getScale()) { result *= 2; } return result; } @Override public void onTileLoaded(int x, int y, int sampleSize) { mRefreshable.refresh(); } @Override public void onTileLoaderOutOfMemory() { mListener.onImageViewDLMOutOfMemory(); } @Override public void onTileLoaderException(Throwable t) { mListener.onImageViewDLMException(t); } public void resetTouchState(){ mCurrentTouchState = null; } }