Привет, Хабр! Не так давно в поисках приключений, новых проектов и технологий я стал роботом устроился в Redmadrobot. Получил стул, монитор и макбук, а для разогрева — небольшой внутренний проект. Нужно было допилить и опубликовать самописную библиотеку для просмотра медиаконтента, которую мы используем в наших проектах. В статье я расскажу, как за неделю разобраться в touch events, стать опенсурсером, найти багу в Android sdk и опубликовать библиотеку.
Начало
Одна из важных фич наших приложений-магазинов — возможность просматривать видео и фото товаров и услуг вблизи и со всех сторон. Мы не хотели изобретать велосипед и отправились на поиски готовой библиотеки, которая бы нас устроила.
Планировали найти такое решение, чтобы пользователь мог:
- просматривать фотографии;
- масштабировать фото при помощи pinch to zoom и double tap;
- просматривать видео;
- листать медиаконтент;
- закрывать карточку с фото вертикальным свайпом (swipe to dismiss).
Вот, что мы нашли:
- FrescoImageViewer — поддерживает просмотр и пролистывание фото и основные жесты, однако не поддерживает просмотр видео и предназначен для библиотеки Fresco.
- PhotoView — поддерживает просмотр фото, большинство основных жестов управления, кроме пролистывания, swipe to dismiss, не поддерживает просмотр видео.
- PhotoDraweeView — аналогичная по функциональности PhotoView, но предназначена для Fresco.
Так как ни одна из найденных библиотек полностью не соотвествовала требованиям, нам пришлось написать свою.
Реализуем библиотеку
Чтобы получить нужную функциональность, мы доработали существующие решения из других библиотек. Тому, что получилось, решили дать скромное название Android Gallery.
Реализуем функциональность
Просмотр и масштабирование фотографий
Для просмотра фотографий взяли библиотеку PhotoView, которая из коробки поддерживает масштабирование.
Просмотр видео
Для просмотра видео взяли ExoPlayer, который переиспользутеся в MediaPagerAdapter. Когда пользователь открывает видео впервые, создаётся ExoPlayer. При переходе к другому элементу он ставится в очередь, так что при следующем запуске видео использоваться будет уже созданный экземпляр ExoPlayer. Это делает переход между элементами более плавным.
Перелистывание медиаконтента
Здесь мы использовали MultiTouchViewPager из FrescoImageViewer, который не перехватывает multi touch events, поэтому мы смогли добавить к нему жесты для масштабирования изображения.
Swipe to dismiss
В PhotoView не было поддержки swipe to dismiss и дебаунса (восстановления исходного размера картинки, когда картинка масштабируется в большую или меньшую сторону).
Вот как нам удалось с этим справиться.
Изучаем touch events для реализации swipe to dismiss
Прежде, чем перейти к поддержке swipe to dismiss, нужно разобраться, как работают touch events. Когда пользователь касается экрана, в текущей Activity вызывается метод dispatchTouchEvent(motionEvent: MotionEvent)
, куда попадает MotionEvent.ACTION_DOWN
. Этот метод решает дальнейшую судьбу события. Можно передать motionEvent
в onTouchEvent(motionEvent: MotionEvent)
на обработку касания или пустить дальше, сверху вниз по иерархии View. View, которая заинтересована в событии и/или в последующих событиях до ACTION_UP
, возвращает true.
После все события текущего жеста (gesture) будут попадать в это View, пока жест не завершится событием ACTION_UP
или родительский ViewGroup не перехватит управление (тогда во View придет событие ACTION_CANCELED
). Если событие обошло всю иерархию View и никого не заинтересовало, оно возвращается обратно в Activity в onTouchEvent(motionEvent: MotionEvent)
.
В нашей библиотеке Android Gallery первое событие ACTION_DOWN
доходит до dispatchTouchEvent()
в PhotoView, где motionEvent
передаётся в реализацию onTouch()
, которая возвращает true. Дальше все события проходят такую же цепочку, пока не произойдёт одно из:
ACTION_UP
;- ViewPager попытается перехватить событие для пролистывания;
- VerticalDragLayout попытается перехватить событие для swipe to dismiss.
Перехват событий может осуществлять только ViewGroup в методе onInterceptTouchEvent(motionEvent: MotionEvent)
. Даже если View заинтересована в каком-либо MotionEvent, само событие будет проходить через dispatchTouchEvent(motionEvent: MotionEvent)
всей предшествующей цепочки ViewGroup. Соответственно родители всегда «наблюдают» за своими детьми. Любой родительский ViewGroup может перехватить событие и вернуть true в методе onInterceptTouchEvent(motionEvent: MotionEvent)
, тогда все дочерние View получат MotionEvent.ACTION_CANCEL
в onTouchEvent(motionEvent: MotionEvent)
.
Пример: пользователь удерживает палец на некотором элементе в RecyclerView, тогда события обрабатываются в этом же элементе. Но как только он начнёт двигать пальцем вверх/вниз, RecyclerView перехватит события, и начнётся скролл, а View получит событие ACTION_CANCEL
.
В Android Gallery VerticalDragLayout может перехватывать события для swipe to dismiss или ViewPager — для перелистывания. Но View может запретить родителю перехватывать события, вызвав метод requestDisallowInterceptTouchEvent(true)
. Это может понадобиться, если View нужно совершить такие действия, перехват родителем которых для нас не желателен.
Например, когда пользователь в плеере проматывает трек к конкретному времени. Если бы родительский ViewPager перехватил горизонтальный скролл, произошёл бы переход к следующему треку.
Для обработки swipe to dismiss мы написали VerticalDragLayout, но он не получал touch событий от PhotoView. Чтобы понять почему так происходит, пришлось разобраться, как обрабатываются touch события в PhotoView.
Порядок обработки:
- При MotionEvent.ACTION_DOWN в VerticalDragLayout срабатывает
interceptTouchEvent()
, который возвращает false, т.к. данный ViewGroup интересуют только вертикальные ACTION_MOVE. Направление ACTION_MOVE определяется вdispatchTouchEvent()
, после чего событие передаётся в методsuper.dispatchTouchEvent()
во ViewGroup, где происходит передача события в реализациюinterceptTouchEvent()
в VerticalDragLayout.
- Когда событие
ACTION_DOWN
доходит до методаonTouch()
в PhotoView, то вьюха отбирает возможность перехватывать управление событиями. Все последующие события жеста не попадают в методinterceptTouchEvent()
. Возможность перехватывать управление отдаётся родителю только в случае завершения жеста или если происходит горизонтальныйACTION_MOVE
у правой/левой границы изображения.
if (mAllowParentInterceptOnEdge && !mScaleDragDetector.isScaling() && !mBlockParentIntercept) { if (mScrollEdge == EDGE_BOTH || (mScrollEdge == EDGE_LEFT && dx >= 1f) || (mScrollEdge == EDGE_RIGHT && dx <= -1f)) { if (parent != null) { parent.requestDisallowInterceptTouchEvent(false); } } } else { if (parent != null) { parent.requestDisallowInterceptTouchEvent(true); } }
Так как PhotoView разрешает родителю перехватывать управление только в случае горизонтальногоACTION_MOVE
, а swipe to dismiss — это вертикальныйACTION_MOVE
, то VerticalDragLayout не может перехватить управление событиями для осуществления жеста. Для исправления нужно добавить возможность перехватывать управления в случае вертикальногоACTION_MOVE
.
if (mAllowParentInterceptOnEdge && !mScaleDragDetector.isScaling() && !mBlockParentIntercept) { if (mHorizontalScrollEdge == HORIZONTAL_EDGE_BOTH || (mHorizontalScrollEdge == HORIZONTAL_EDGE_LEFT && dx >= 1f) || (mHorizontalScrollEdge == HORIZONTAL_EDGE_RIGHT && dx <= -1f) || mVerticalScrollEdge == VERTICAL_EDGE_BOTH || (mVerticalScrollEdge == VERTICAL_EDGE_TOP && dy >= 1f) || (mVerticalScrollEdge == VERTICAL_EDGE_BOTTOM && dy <= -1f)) { if (parent != null) { parent.requestDisallowInterceptTouchEvent(false); } } } else { if (parent != null) { parent.requestDisallowInterceptTouchEvent(true); } }
Теперь в случае первого вертикального
ACTION_MOVE
PhotoView будет отдавать возможность перехвата родителю:
СледующийACTION_MOVE
будет перехвачен в VerticalDragLyout, при этом в дочерние View прилетит событиеACTION_CANCEL
:
Все остальныеACTION_MOVE
будут прилетать в VerticalDragLayout по стандартной цепочке. Важно, что после того как ViewGroup перехватывает управление событиями у дочернего View, дочерние View никак не могут вернуть себе управление.
Так мы реализовали поддержку swipe to dismiss для библиотеки PhotoView. В нашей библиотеке мы использовали вынесенные в отдельный модуль доработанные исходники PhotoView, а в оригинальный репозиторий PhotoView создали merge request.
Реализуем дебаунс в PhotoView
Напомним, что дебаунс — это анимация-восстановление допустимого масштаба, когда изображение масштабируется за его пределы.
В PhotoView такой возможности не было. Но раз уж мы начали копать чужой опенсорс, зачем останавливаться на достигнутом? В PhotoView можно задать ограничение на зум. Изначально это минимальный — х1 и максимальный — х3. За эти пределы изображение выйти не может.
@Override public void onScale(float scaleFactor, float focusX, float focusY) { if ((getScale() < mMaxScale || scaleFactor < 1f) && (getScale() > mMinScale || scaleFactor > 1f)) { if (mScaleChangeListener != null) { mScaleChangeListener.onScaleChange(scaleFactor, focusX, focusY); } mSuppMatrix.postScale(scaleFactor, scaleFactor, focusX, focusY); // вот тут вся математика зума checkAndDisplayMatrix(); } }
Для начала мы решили убрать условие «запрет масштабирования по достижении минимума»: просто выкинули условие
getScale() > mMinScale || scaleFactor > 1f
. И тут внезапно…
Дебаунс заработал! Видимо, так произошло из-за того, что создатели библиотеки решили дважды подстраховаться, сделав и дебаунс, и ограничение на масштабирование. В реализации события onTouch, а именно в случаеMotionEvent.ACTION_UP
, если пользователь отмасштабировался больше/меньше максимума/минимума, запускается AnimatedZoomRunnable, который возвращает картинку к исходному размеру.
@Override public boolean onTouch(View v, MotionEvent ev) { boolean handled = false; switch (ev.getAction()) { case MotionEvent.ACTION_UP: // If the user has zoomed less than min scale, zoom back // to min scale if (getScale() < mMinScale) { RectF rect = getDisplayRect(); if (rect != null) { v.post(new AnimatedZoomRunnable(getScale(), mMinScale, rect.centerX(), rect.centerY())); handled = true; } } else if (getScale() > mMaxScale) { RectF rect = getDisplayRect(); if (rect != null) { v.post(new AnimatedZoomRunnable(getScale(), mMaxScale, rect.centerX(), rect.centerY())); handled = true; } } break; } }
Также как и с swipe to dismiss, мы доработали PhotoView в исходниках нашей библиотеки и создали pull request с «добавлением» дебаунса в оригинальный PhotoView.
Исправляем внезапный баг в PhotoView
В PhotoView есть очень неприятный баг. Когда пользователь хочет увеличить изображение двойным тапом и
у него случается приступ эпилепсииизображение начинает масштабироваться, оно может перевернуться на 180 градусов по вертикали. Этот баг можно встретить даже в популярных приложениях из Google Play, например, в ЦИАНе.
После долгого поиска мы всё-таки локализовали этот баг: иногда в матричное преобразование изображения для масштабирования на вход подаётся отрицательный scaleFactor, он-то и вызывает переворот изображения.
@Override public boolean onScale(ScaleGestureDetector detector) { // по какой-то причине иногда scaleFactor < 0 // это приводит к перевороту изображения float scaleFactor = detector.getScaleFactor(); if (Float.isNaN(scaleFactor) || Float.isInfinite(scaleFactor)) return false; // а вот тут scaleFactor передаётся в callback // в котором происходит матричное преобразование mListener.onScale(scaleFactor, detector.getFocusX(), detector.getFocusY()); return true; }
Для масштабирования из андроидовского ScaleGestureDetector достаём scaleFactor, который вычисляется следующим образом:
public float getScaleFactor() { if (inAnchoredScaleMode()) { // Drag is moving up; the further away from the gesture // start, the smaller the span should be, the closer, // the larger the span, and therefore the larger the scale final boolean scaleUp = (mEventBeforeOrAboveStartingGestureEvent && (mCurrSpan < mPrevSpan)) || (!mEventBeforeOrAboveStartingGestureEvent && (mCurrSpan > mPrevSpan)); final float spanDiff = (Math.abs(1 - (mCurrSpan / mPrevSpan)) * SCALE_FACTOR); return mPrevSpan <= 0 ? 1 : scaleUp ? (1 + spanDiff) : (1 - spanDiff); } return mPrevSpan > 0 ? mCurrSpan / mPrevSpan : 1; }
Если обложить данный метод дебаг-логами, можно отследить, при каких именно значениях переменных получается отрицательный scaleFactor:
mEventBeforeOrAboveStartingGestureEvent is true; SCALE_FACTOR is 0.5; mCurrSpan: 1075.4398; mPrevSpan 38.867798; scaleUp: false; spanDiff: 13.334586; eval result is -12.334586
Есть подозрение, что эту проблему пытались решить путём домножения spanDiff на SCALE_FACTOR == 0.5. Но это решение не поможет, если разница между mCurrSpan и mPrevSpan больше, чем в три раза. На этот баг уже даже заведён тикет, однако он до сих пор не исправлен.
КостыльноеСамое простое решение этой проблемы — просто пропускать отрицательные значения scaleFactor. На практике пользователь не заметит, что изображение иногда зумируется чуть менее плавно, чем обычно.
Вместо заключения
Судьба пулл реквестов
Мы сделали локальное исправление и создали последний Pull Request в PhotoView. Несмотря на то, что некоторые PR висят там уже год, наши PR были добавлены в master-ветку и даже был выпущен новый релиз PhotoView. После чего мы решили выпилить локальный модуль из Android Gallery и подтянуть официальные исходники PhotoView. Для этого пришлось добавить поддержку AndroidX, который был добавлен в PhotoView в версии 2.1.3.
Где найти библиотеку
Исходный код библиотеки Android Gallery ищите тут — https://github.com/redmadrobot-spb/android-gallery, вместе с инструкцией по использованию. А для поддержки проектов, которые всё ещё используют Support Library, мы создали отдельную версию android-gallery-deprecated. Но будьте осторожны, ведь через год Support Library превратится в тыкву!
Что дальше
Сейчас библиотека полностью нас устраивает, но в процессе разработки возникли новые идеи. Вот некоторые из них:
- возможность использовать библиотеку в любой вёрстке, а не только отдельным FragmentDialog;
- возможность кастомизации UI;
- возможность подмены Gilde и ExoPlayer;
- возможность использовать что-то вместо ViewPager.
Ссылки
- The android touch system from a slightly different perspective — очень хорошая статья по теме со ссылками на другие отличные статьи и видео, в котором подробно описан принцип работы Android Touch System.
- Овладение жестами в Android — статья про Android Touch System на русском.
UPD
Пока писали статью, вышла похожая библиотека от разработчиков FrescoImageViewer. Они добавили поддержку transition animation, однако поддержка видео пока что есть только у нас. :)