package fi.harism.curl; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.PointF; import android.graphics.RectF; import android.opengl.GLSurfaceView; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; @SuppressLint("FloatMath") public class CurlView extends GLSurfaceView implements View.OnTouchListener, CurlRenderer.Observer { // Curl state. We are flipping none, left or right page. private static final int CURL_LEFT = 1; private static final int CURL_NONE = 0; private static final int CURL_RIGHT = 2; // Constants for mAnimationTargetEvent. private static final int SET_CURL_TO_LEFT = 1; private static final int SET_CURL_TO_RIGHT = 2; // Shows one page at the center of view. public static final int SHOW_ONE_PAGE = 1; // Shows two pages side by side. public static final int SHOW_TWO_PAGES = 2; private boolean mAllowLastPageCurl = true; private boolean mAnimate = false; private long mAnimationDurationTime = 300; private PointF mAnimationSource = new PointF(); private long mAnimationStartTime; private PointF mAnimationTarget = new PointF(); private int mAnimationTargetEvent; private PointF mCurlDir = new PointF(); private PointF mCurlPos = new PointF(); private int mCurlState = CURL_NONE; // Current bitmap index. This is always showed as front of right page. private int mCurrentIndex = 0; // Start position for dragging. private PointF mDragStartPos = new PointF(); private boolean mEnableTouchPressure = false; // Bitmap size. These are updated from renderer once it's initialized. private int mPageBitmapHeight = -1; private int mPageBitmapWidth = -1; // Page meshes. Left and right meshes are 'static' while curl is used to // show page flipping. private CurlMesh mPageCurl; private CurlMesh mPageLeft; private PageProvider mPageProvider; private CurlMesh mPageRight; private PointerPosition mPointerPos = new PointerPosition(); private CurlRenderer mRenderer; private boolean mRenderLeftPage = true; private SizeChangedObserver mSizeChangedObserver; // One page is the default. private int mViewMode = SHOW_ONE_PAGE; private boolean RIGHT_TO_LEFT = true; /** * Default constructor. */ public CurlView(Context ctx) { super(ctx); init(ctx); } /** * Default constructor. */ public CurlView(Context ctx, AttributeSet attrs) { super(ctx, attrs); init(ctx); } /** * Default constructor. */ public CurlView(Context ctx, AttributeSet attrs, int defStyle) { this(ctx, attrs); } /** * Get current page index. Page indices are zero based values presenting * page being shown on right side of the book. */ public int getCurrentIndex() { return mCurrentIndex; } /** * Initialize method. */ private void init(Context ctx) { mRenderer = new CurlRenderer(this); setRenderer(mRenderer); setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY); setOnTouchListener(this); // Even though left and right pages are static we have to allocate room // for curl on them too as we are switching meshes. Another way would be // to swap texture ids only. mPageLeft = new CurlMesh(10); mPageRight = new CurlMesh(10); mPageCurl = new CurlMesh(10); mPageLeft.setFlipTexture(true); mPageRight.setFlipTexture(false); } @Override public void onDrawFrame() { //TODO:on draw frame // We are not animating. if (mAnimate == false) { return; } long currentTime = System.currentTimeMillis(); // If animation is done. if (currentTime >= mAnimationStartTime + mAnimationDurationTime) { if (mAnimationTargetEvent == SET_CURL_TO_RIGHT) { // Switch curled page to right. CurlMesh right = mPageCurl; CurlMesh curl = mPageRight; right.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT)); right.setFlipTexture(false); right.reset(); mRenderer.removeCurlMesh(curl); mPageCurl = curl; mPageRight = right; // If we were curling left page update current index. if (mCurlState == CURL_LEFT) { --mCurrentIndex; } } else if (mAnimationTargetEvent == SET_CURL_TO_LEFT) { // Switch curled page to left. CurlMesh left = mPageCurl; CurlMesh curl = mPageLeft; left.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_LEFT)); left.setFlipTexture(true); left.reset(); mRenderer.removeCurlMesh(curl); if (!mRenderLeftPage) { mRenderer.removeCurlMesh(left); } mPageCurl = curl; mPageLeft = left; // If we were curling right page update current index. if (mCurlState == CURL_RIGHT) { ++mCurrentIndex; } } mCurlState = CURL_NONE; mAnimate = false; requestRender(); } else { mPointerPos.mPos.set(mAnimationSource); float t = 1f - ((float) (currentTime - mAnimationStartTime) / mAnimationDurationTime); t = 1f - (t * t * t * (3 - 2 * t)); mPointerPos.mPos.x += (mAnimationTarget.x - mAnimationSource.x) * t; mPointerPos.mPos.y += (mAnimationTarget.y - mAnimationSource.y) * t; updateCurlPos(mPointerPos); } } @Override public void onPageSizeChanged(int width, int height) { mPageBitmapWidth = width; mPageBitmapHeight = height; updatePages(); requestRender(); } @Override public void onSizeChanged(int w, int h, int ow, int oh) { super.onSizeChanged(w, h, ow, oh); requestRender(); if (mSizeChangedObserver != null) { mSizeChangedObserver.onSizeChanged(w, h); } } @Override public void onSurfaceCreated() { // In case surface is recreated, let page meshes drop allocated texture // ids and ask for new ones. There's no need to set textures here as // onPageSizeChanged should be called later on. mPageLeft.resetTexture(); mPageRight.resetTexture(); mPageCurl.resetTexture(); } @Override public boolean onTouch(View view, MotionEvent me) { // No dragging during animation at the moment. // TODO: Stop animation on touch event and return to drag mode. if (mAnimate || mPageProvider == null) { return false; } // We need page rects quite extensively so get them for later use. RectF rightRect = mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT); RectF leftRect = mRenderer.getPageRect(CurlRenderer.PAGE_LEFT); // Store pointer position. mPointerPos.mPos.set(me.getX(), me.getY()); mRenderer.translate(mPointerPos.mPos); if (mEnableTouchPressure) { mPointerPos.mPressure = me.getPressure(); } else { mPointerPos.mPressure = 0.8f; } switch (me.getAction()) { case MotionEvent.ACTION_DOWN: { // Once we receive pointer down event its position is mapped to // right or left edge of page and that'll be the position from where // user is holding the paper to make curl happen. mDragStartPos.set(mPointerPos.mPos); //mDragStartPos.y = 0.0f; // First we make sure it's not over or below page. Pages are // supposed to be same height so it really doesn't matter do we use // left or right one. if (mDragStartPos.y > rightRect.top) { mDragStartPos.y = rightRect.top; } else if (mDragStartPos.y < rightRect.bottom) { mDragStartPos.y = rightRect.bottom; } // Then we have to make decisions for the user whether curl is going // to happen from left or right, and on which page. if (mViewMode == SHOW_TWO_PAGES) { // If we have an open book and pointer is on the left from right // page we'll mark drag position to left edge of left page. // Additionally checking mCurrentIndex is higher than zero tells // us there is a visible page at all. if (mDragStartPos.x < rightRect.left && mCurrentIndex > 0) { mDragStartPos.x = leftRect.left; startCurl(CURL_LEFT); } // Otherwise check pointer is on right page's side. else if (mDragStartPos.x >= rightRect.left && mCurrentIndex < mPageProvider.getPageCount()) { mDragStartPos.x = rightRect.right; if (!mAllowLastPageCurl && mCurrentIndex >= mPageProvider.getPageCount() - 1) { return false; } startCurl(CURL_RIGHT); } } else if (mViewMode == SHOW_ONE_PAGE) { float halfX = (leftRect.right + leftRect.left) / 2; if (mDragStartPos.x < halfX && mCurrentIndex > 0) { mDragStartPos.x = leftRect.left; startCurl(CURL_LEFT); } else if (mDragStartPos.x >= halfX && mCurrentIndex < mPageProvider.getPageCount()) { mDragStartPos.x = rightRect.left; if (!mAllowLastPageCurl && mCurrentIndex >= mPageProvider.getPageCount() - 1) { return false; } startCurl(CURL_RIGHT); } } // If we have are in curl state, let this case clause flow through // to next one. We have pointer position and drag position defined // and this will create first render request given these points. if (mCurlState == CURL_NONE) { return false; } } case MotionEvent.ACTION_MOVE: { updateCurlPos(mPointerPos); break; } case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: { if (mCurlState == CURL_LEFT || mCurlState == CURL_RIGHT) { // Animation source is the point from where animation starts. // Also it's handled in a way we actually simulate touch events // meaning the output is exactly the same as if user drags the // page to other side. While not producing the best looking // result (which is easier done by altering curl position and/or // direction directly), this is done in a hope it made code a // bit more readable and easier to maintain. mAnimationSource.set(mPointerPos.mPos); mAnimationStartTime = System.currentTimeMillis(); // Given the explanation, here we decide whether to simulate // drag to left or right end. if ((mViewMode == SHOW_ONE_PAGE && mPointerPos.mPos.x < (leftRect.left + leftRect.right) / 2) || mViewMode == SHOW_TWO_PAGES && mPointerPos.mPos.x < rightRect.left) { // On right side target is always right page's right border. mAnimationTarget.set(mDragStartPos); mAnimationTarget.x = mRenderer.getPageRect(CurlRenderer.PAGE_LEFT).left; mAnimationTargetEvent = SET_CURL_TO_LEFT; } else { // On left side target depends on visible pages. mAnimationTarget.set(mDragStartPos); if (mCurlState == CURL_RIGHT && mViewMode == SHOW_TWO_PAGES || mCurlState == CURL_LEFT && mPointerPos.mPos.x > rightRect.left) { mAnimationTarget.x = rightRect.right; } else { mAnimationTarget.x = leftRect.right; } mAnimationTargetEvent = SET_CURL_TO_RIGHT; } mAnimate = true; requestRender(); } break; } } return true; } /** * Allow the last page to curl. */ public void setAllowLastPageCurl(boolean allowLastPageCurl) { mAllowLastPageCurl = allowLastPageCurl; } /** * Sets background color - or OpenGL clear color to be more precise. Color * is a 32bit value consisting of 0xAARRGGBB and is extracted using * android.graphics.Color eventually. */ @Override public void setBackgroundColor(int color) { mRenderer.setBackgroundColor(color); requestRender(); } /** * Sets mPageCurl curl position. */ //TODO:startCurlPost private void setCurlPos(PointF curlPos, PointF curlDir, double radius) { if (mCurlState == CURL_LEFT || (mCurlState == CURL_RIGHT && mViewMode == SHOW_ONE_PAGE)) { RectF pageRect = mRenderer.getPageRect(CurlRenderer.PAGE_LEFT); if (curlPos.x <= pageRect.left) { mPageCurl.reset(); requestRender(); return; } if (curlPos.x > pageRect.right) { curlPos.x = pageRect.right; } if (curlDir.y != 0) { float diffX = curlPos.x - pageRect.right; float rightY = curlPos.y + (diffX * curlDir.x / curlDir.y); if (curlDir.y < 0 && rightY < pageRect.top) { curlDir.x = pageRect.top - curlPos.y; curlDir.y = curlPos.x - pageRect.right; } else if (curlDir.y > 0 && rightY > pageRect.bottom) { curlDir.x = curlPos.y - pageRect.bottom; curlDir.y = pageRect.right - curlPos.x; } } }else if(mCurlState == CURL_RIGHT){ RectF pageRect = mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT); if (curlPos.x >= pageRect.right) { mPageCurl.reset(); requestRender(); return; } if (curlPos.x < pageRect.left) { curlPos.x = pageRect.left; } if (curlDir.y != 0) { float diffX = curlPos.x - pageRect.left; float leftY = curlPos.y + (diffX * curlDir.x / curlDir.y); if (curlDir.y < 0 && leftY < pageRect.top) { curlDir.x = curlPos.y - pageRect.top; curlDir.y = pageRect.left - curlPos.x; } else if (curlDir.y > 0 && leftY > pageRect.bottom) { curlDir.x = pageRect.bottom - curlPos.y; curlDir.y = curlPos.x - pageRect.left; } } } // Finally normalize direction vector and do rendering. double dist = Math.sqrt(curlDir.x * curlDir.x + curlDir.y * curlDir.y); if (dist != 0) { curlDir.x /= dist; curlDir.y /= dist; mPageCurl.curl(curlPos, curlDir, radius); } else { mPageCurl.reset(); } requestRender(); } /** * Set current page index. Page indices are zero based values presenting * page being shown on right side of the book. E.g if you set value to 4; * right side front facing bitmap will be with index 4, back facing 5 and * for left side page index 3 is front facing, and index 2 back facing (once * page is on left side it's flipped over). * * Current index is rounded to closest value divisible with 2. */ public void setCurrentIndex(int index) { if (mPageProvider == null || index < 0) { mCurrentIndex = 0; } else { if (mAllowLastPageCurl) { mCurrentIndex = Math.min(index, mPageProvider.getPageCount()); } else { mCurrentIndex = Math.min(index, mPageProvider.getPageCount() - 1); } } updatePages(); requestRender(); } /** * If set to true, touch event pressure information is used to adjust curl * radius. The more you press, the flatter the curl becomes. This is * somewhat experimental and results may vary significantly between devices. * On emulator pressure information seems to be flat 1.0f which is maximum * value and therefore not very much of use. */ public void setEnableTouchPressure(boolean enableTouchPressure) { mEnableTouchPressure = enableTouchPressure; } /** * Set margins (or padding). Note: margins are proportional. Meaning a value * of .1f will produce a 10% margin. */ public void setMargins(float left, float top, float right, float bottom) { mRenderer.setMargins(left, top, right, bottom); } /** * Update/set page provider. */ public void setPageProvider(PageProvider pageProvider) { mPageProvider = pageProvider; mCurrentIndex = 0; updatePages(); requestRender(); } /** * Setter for whether left side page is rendered. This is useful mostly for * situations where right (main) page is aligned to left side of screen and * left page is not visible anyway. */ public void setRenderLeftPage(boolean renderLeftPage) { mRenderLeftPage = renderLeftPage; } /** * Sets SizeChangedObserver for this View. Call back method is called from * this View's onSizeChanged method. */ public void setSizeChangedObserver(SizeChangedObserver observer) { mSizeChangedObserver = observer; } /** * Sets view mode. Value can be either SHOW_ONE_PAGE or SHOW_TWO_PAGES. In * former case right page is made size of display, and in latter case two * pages are laid on visible area. */ public void setViewMode(int viewMode) { switch (viewMode) { case SHOW_ONE_PAGE: mViewMode = viewMode; mPageLeft.setFlipTexture(false); mRenderer.setViewMode(CurlRenderer.SHOW_ONE_PAGE); mRenderer.setRightToLeft(RIGHT_TO_LEFT); break; case SHOW_TWO_PAGES: mViewMode = viewMode; mPageLeft.setFlipTexture(false); mRenderer.setViewMode(CurlRenderer.SHOW_TWO_PAGES); break; } } /** * Switches meshes and loads new bitmaps if available. Updated to support 2 * pages in landscape */ private void startCurl(int page) { switch (page) { //TODO:starcurl // Once right side page is curled, first right page is assigned into // curled page. And if there are more bitmaps available new bitmap is // loaded into right side mesh. case CURL_RIGHT: { // Remove meshes from renderer. mRenderer.removeCurlMesh(mPageLeft); mRenderer.removeCurlMesh(mPageRight); mRenderer.removeCurlMesh(mPageCurl); // We are curling right page. CurlMesh curl = mPageRight; mPageRight = mPageCurl; mPageCurl = curl; if (mCurrentIndex > 0) { mPageLeft.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_LEFT)); mPageLeft.setFlipTexture(true); mPageLeft.reset(); if (mRenderLeftPage) { mRenderer.addCurlMesh(mPageLeft); } } if (mCurrentIndex < mPageProvider.getPageCount() - 1) { updatePage(mPageRight.getTexturePage(), mCurrentIndex + 1); mPageRight.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT)); mPageRight.setFlipTexture(false); mPageRight.reset(); mRenderer.addCurlMesh(mPageRight); } // How dragging previous page happens depends on view mode. if (mViewMode == SHOW_ONE_PAGE || (mCurlState == CURL_LEFT && mViewMode == SHOW_TWO_PAGES)) { mPageCurl.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_LEFT)); mPageCurl.setFlipTexture(true); } else { mPageCurl.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT)); mPageCurl.setFlipTexture(false); } mPageCurl.reset(); mRenderer.addCurlMesh(mPageCurl); mCurlState = CURL_RIGHT; break; } // On left side curl, left page is assigned to curled page. And if // there are more bitmaps available before currentIndex, new bitmap // is loaded into left page. case CURL_LEFT: { // Remove meshes from renderer. mRenderer.removeCurlMesh(mPageLeft); mRenderer.removeCurlMesh(mPageRight); mRenderer.removeCurlMesh(mPageCurl); // We are curling left page. CurlMesh curl = mPageLeft; mPageLeft = mPageCurl; mPageCurl = curl; if (mCurrentIndex > 1) { updatePage(mPageLeft.getTexturePage(), mCurrentIndex - 2); mPageLeft.setFlipTexture(true); mPageLeft.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_LEFT)); mPageLeft.reset(); if (mRenderLeftPage) { mRenderer.addCurlMesh(mPageLeft); } } // If there is something to show on right page add it to renderer. if (mCurrentIndex < mPageProvider.getPageCount()) { mPageRight.setFlipTexture(false); mPageRight.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT)); mPageRight.reset(); mRenderer.addCurlMesh(mPageRight); } mPageCurl.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_LEFT)); mPageCurl.setFlipTexture(true); mPageCurl.reset(); mRenderer.addCurlMesh(mPageCurl); mCurlState = CURL_LEFT; break; } } } /** * Updates curl position. */ @SuppressLint("FloatMath") private void updateCurlPos(PointerPosition pointerPos) { // Default curl radius. double radius = mRenderer.getPageRect(CURL_RIGHT).width() / 3; // TODO: This is not an optimal solution. Based on feedback received so // far; pressure is not very accurate, it may be better not to map // coefficient to range [0f, 1f] but something like [.2f, 1f] instead. // Leaving it as is until get my hands on a real device. On emulator // this doesn't work anyway. radius *= Math.max(1f - pointerPos.mPressure, 0f); // NOTE: Here we set pointerPos to mCurlPos. It might be a bit confusing // later to see e.g "mCurlPos.x - mDragStartPos.x" used. But it's // actually pointerPos we are doing calculations against. Why? Simply to // optimize code a bit with the cost of making it unreadable. Otherwise // we had to this in both of the next if-else branches. mCurlPos.set(pointerPos.mPos); //mCurlPos.y = 0.0f; // If curl happens on right page, or on left page on two page mode, // we'll calculate curl position from pointerPos. if (mCurlState == CURL_LEFT || (mCurlState == CURL_RIGHT && mViewMode == SHOW_TWO_PAGES)) { mCurlDir.x = mCurlPos.x - mDragStartPos.x; mCurlDir.y = mCurlPos.y - mDragStartPos.y; float dist = (float) Math.sqrt(mCurlDir.x * mCurlDir.x + mCurlDir.y * mCurlDir.y); // Adjust curl radius so that if page is dragged far enough on // opposite side, radius gets closer to zero. float pageWidth = mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT) .width(); double curlLen = radius * Math.PI; if (dist > (pageWidth * 2) - curlLen) { curlLen = Math.max((pageWidth * 2) - dist, 0f); radius = curlLen / Math.PI; } // Actual curl position calculation. if (dist >= curlLen) { double translate = (dist - curlLen) / 2; if (mViewMode == SHOW_TWO_PAGES) { mCurlPos.x -= mCurlDir.x * translate / dist; } else { float pageLeftX = mRenderer.getPageRect(CurlRenderer.PAGE_LEFT).left; radius = Math.max(Math.min(mCurlPos.x - pageLeftX, radius), 0f); } mCurlPos.y -= mCurlDir.y * translate / dist; } else { double angle = Math.PI * Math.sqrt(dist / curlLen); double translate = radius * Math.sin(angle); mCurlPos.x += mCurlDir.x * translate / dist; mCurlPos.y += mCurlDir.y * translate / dist; } } // Otherwise we'll let curl follow pointer position. else if (mCurlState == CURL_RIGHT) { // Adjust radius regarding how close to page edge we are. float pageLeftX = mRenderer.getPageRect(CurlRenderer.PAGE_LEFT).left; radius = Math.max(Math.min(mCurlPos.x - pageLeftX, radius), 0f); float pageRightX = mRenderer.getPageRect(CurlRenderer.PAGE_LEFT).right; mCurlPos.x -= Math.min(pageRightX - mCurlPos.x, radius); mCurlDir.x -= mCurlPos.x - mDragStartPos.x; mCurlDir.y = mCurlPos.y - mDragStartPos.y; } setCurlPos(mCurlPos, mCurlDir, radius); } /** * Updates given CurlPage via PageProvider for page located at index. */ private void updatePage(CurlPage page, int index) { // First reset page to initial state. page.reset(); // Ask page provider to fill it up with bitmaps and colors. mPageProvider.updatePage(page, mPageBitmapWidth, mPageBitmapHeight, index); } /** * Updates bitmaps for page meshes. */ private void updatePages() { if (mPageProvider == null || mPageBitmapWidth <= 0 || mPageBitmapHeight <= 0) { return; } // Remove meshes from renderer. mRenderer.removeCurlMesh(mPageLeft); mRenderer.removeCurlMesh(mPageRight); mRenderer.removeCurlMesh(mPageCurl); int leftIdx = mCurrentIndex - 1; int rightIdx = mCurrentIndex; int curlIdx = -1; if (mCurlState == CURL_LEFT) { curlIdx = leftIdx; --leftIdx; } else if (mCurlState == CURL_RIGHT) { curlIdx = rightIdx; ++rightIdx; } if (rightIdx >= 0 && rightIdx < mPageProvider.getPageCount()) { updatePage(mPageRight.getTexturePage(), rightIdx); mPageRight.setFlipTexture(false); mPageRight.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_RIGHT)); mPageRight.reset(); mRenderer.addCurlMesh(mPageRight); } if (leftIdx >= 0 && leftIdx < mPageProvider.getPageCount()) { updatePage(mPageLeft.getTexturePage(), leftIdx); mPageLeft.setFlipTexture(true); mPageLeft.setRect(mRenderer.getPageRect(CurlRenderer.PAGE_LEFT)); mPageLeft.reset(); if (mRenderLeftPage) { mRenderer.addCurlMesh(mPageLeft); } } if (curlIdx >= 0 && curlIdx < mPageProvider.getPageCount()) { updatePage(mPageCurl.getTexturePage(), curlIdx); if (mCurlState == CURL_RIGHT) { mPageCurl.setFlipTexture(true); mPageCurl.setRect(mRenderer .getPageRect(CurlRenderer.PAGE_RIGHT)); } else { mPageCurl.setFlipTexture(false); mPageCurl .setRect(mRenderer.getPageRect(CurlRenderer.PAGE_LEFT)); } mPageCurl.reset(); mRenderer.addCurlMesh(mPageCurl); } } /** * Provider for feeding 'book' with bitmaps which are used for rendering * pages. */ public interface PageProvider { /** * Return number of pages available. */ public int getPageCount(); /** * Called once new bitmaps/textures are needed. Width and height are in * pixels telling the size it will be drawn on screen and following them * ensures that aspect ratio remains. But it's possible to return bitmap * of any size though. You should use provided CurlPage for storing page * information for requested page number.<br/> * <br/> * Index is a number between 0 and getBitmapCount() - 1. */ public void updatePage(CurlPage page, int width, int height, int index); } /** * Simple holder for pointer position. */ private class PointerPosition { PointF mPos = new PointF(); float mPressure; } /** * Observer interface for handling CurlView size changes. */ public interface SizeChangedObserver { /** * Called once CurlView size changes. */ public void onSizeChanged(int width, int height); } }
Curl Page - harism modify left to right
I have modify code from harism to fit my requirement curl page from left to right like read arabic book. thanks to harism, its cool...
Subscribe to:
Posts (Atom)