Параллакс эффект для живых обоев на Android

    Каждый, кто пробовал установить себе живые обои, замечал параллакс эффект при перемещении между рабочими столами. Выглядит он очень занимательно, но вот в его реализации возникают проблемы, которые и будут освещены в данной статье. Речь пойдет про реализацию параллакс эффекта под живые обои Android.


    Ниже будут рассмотрены стандартный и собственный методы реализации. Указаны недостатки и достоинства каждого из них.

    Стандартный метод


    Начиная с API7, появился класс WallpaperService.Engine с методом onOffsetsChanged. Данный метод вызывается каждый раз, когда рабочий стол меняет свою позицию. Для использования его достаточно переопределить в собственной реализации класса WallpaperService.Engine. Метод имеет следующую сигнатуру:

    onOffsetsChanged(float xOffset, float yOffset, float xOffsetStep, float yOffsetStep, int xPixelOffset, int yPixelOffset)
    


    Из всех передаваемых параметров нас интересуют xOffset и yOffset, а применительно к живым обоям, достаточно использовать xOffset. Этот параметр изменяется от 0 до 1, равен 0 при одном крайнем положении рабочего стола и 1 при другом крайнем положении рабочего стола. Если рабочий стол находится в положении по умолчанию (посередине), параметр xOffset равен 0.5. Например, для 3-х рабочих столов xOffset будет равен соответственно 0, 0.5, 1. При движении от одного рабочего стола к другому параметр изменяется плавно, а метод onOffsetsChanged вызывается многократно. Однако «плавность» может отличаться на разных устройствах.

    Таким образом, передав этот параметр в Renderer ваших обоев, можно смещать их в нужную сторону, реализовав параллакс эффект. Преимущества очевидны: минимум кода и синхронная работа с рабочим столом.

    Все было бы хорошо, если бы не недостатки данного метода:
    • Не все устройства (оболочки) вызывают метод onOffsetsChanged при пролистывании рабочих столов. Что удивительно, чаще это случается с самыми новыми устройствами (например, HTC One X).
    • Не все устройства делают это достаточное количество раз, из-за чего резко падает плавность движения обоев.
    • Если рабочие столы в устройстве «закольцованы», то при переходе с последнего на первый происходит резкая прокрутка обоев.


    Собственный метод, класс ZTouchMove


    Из-за всех этих проблем было решено сделать свое решение, которое бы выполнялось на всех устройствах. Для этого был найден метод onTouchEvent того же класса WallpaperService.Engine. Для использования данного метода предварительно необходимо включить его вызов:
    @Override
    public void onCreate(SurfaceHolder surfaceHolder) {
        setTouchEventsEnabled(true);
    }
    


    Далее этот метод будет принимать все события связанные с касанием экрана. Однако, касания хотелось бы преобразовать в уже полюбившийся формат смещения от 0 до 1 с учетом инерции, анимации движения и прочих радостей. Для этого был написан свой обработчик касаний, который на выходе «выдавал» как раз то, что нужно. Ниже привожу код получившегося обработчика:

    import java.util.ArrayList;
    import java.util.Iterator;
    import java.util.List;
    import android.annotation.SuppressLint;
    import android.content.Context;
    import android.graphics.Point;
    import android.os.Build;
    import android.os.Handler;
    import android.view.Display;
    import android.view.MotionEvent;
    import android.view.VelocityTracker;
    import android.view.ViewConfiguration;
    import android.view.WindowManager;
    import android.view.animation.Interpolator;
    import android.widget.Scroller;
    
    public class ZTouchMove {
    	
    	public interface ZTouchMoveListener {
    	    public void onTouchOffsetChanged(float xOffset);
    	}
    	private List<ZTouchMoveListener> mListeners = new ArrayList<ZTouchMoveListener>();
    	
    	public class ZInterpolator implements Interpolator {
    		public float getInterpolation(float input) {
    			// f(x) = ax^3 + bx^2 + cx + d
    			// a = x - 2
    			// b = 3 - 2x
    			// c = x
    			// d = 0
    			// where x = derivative in point 0
    			//input = (float)(-Math.cos(10*((double)input/Math.PI)) + 1) / 2;
    			input = (mVelocity - 2) * (float) Math.pow(input, 3) + (3 - 2 * mVelocity) * (float) Math.pow(input, 2) + mVelocity * input; 
    			return input;
    		}
    	}
    	
    	Handler mHandler = new Handler();
    	
    	final Runnable mRunnable = new Runnable()
    	{
    	    public void run() 
    	    {
    	    	if(onMovingToPosition())
    	    		mHandler.postDelayed(this, 20);
    	    }
    	};
    	
    	private float mPosition = 0.5f;
    	private float mPositionDelta = 0;
    	private float mTouchDownX;
    	private int xDiff;
    	private VelocityTracker mVelocityTracker;
    	private float mVelocity = 0;
    	private Scroller mScroller;
    	
    	private final static int TOUCH_STATE_REST = 0;
    	private final static int TOUCH_STATE_SCROLLING = 1;
    	private static final int SCROLLING_TIME = 300;
    	private static final int SNAP_VELOCITY = 350;
    	
    	private int mTouchSlop;
    	private int mMaximumVelocity;	
    	private int mTouchState = TOUCH_STATE_REST;
    	
    	private int mWidth;
    	private int mNumVirtualScreens = 5;
    	
    	@SuppressLint("NewApi")
    	@SuppressWarnings("deprecation")
    	public void init(Context ctx) {
    		mScroller = new Scroller(ctx, new ZInterpolator());
    		
    		final ViewConfiguration configuration = ViewConfiguration.get(ctx);
    		mTouchSlop = configuration.getScaledTouchSlop();
    		mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
    
    		WindowManager wm = (WindowManager) ctx.getSystemService(Context.WINDOW_SERVICE);
    		Display display = wm.getDefaultDisplay();
    
    		// API Level 13
    		if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR2) {
    			Point size = new Point();
    			display.getSize(size); 
    			mWidth = size.x;			
    		} else {
    			// API Level <13
    			mWidth = display.getWidth();			
    		}
    	}
    	
    	public void onTouchEvent(MotionEvent e) {
    		if (mVelocityTracker == null) {
    			mVelocityTracker = VelocityTracker.obtain();
    		}
    		mVelocityTracker.addMovement(e);
    		
    		final float x = e.getX();
    		final int action = e.getAction();
    		
    		switch (action) {
    			case MotionEvent.ACTION_DOWN:
    				mTouchState = mScroller.isFinished() ? TOUCH_STATE_REST : TOUCH_STATE_SCROLLING;
    				if (!mScroller.isFinished()) {
    					mScroller.abortAnimation();
    				}
    				
    				mTouchDownX = x;
    				break;
    				
    			case MotionEvent.ACTION_MOVE:
    				xDiff = (int) (x - mTouchDownX);
    				
    				if (Math.abs(xDiff) > mTouchSlop && mTouchState != TOUCH_STATE_SCROLLING) {
    					mTouchState = TOUCH_STATE_SCROLLING;
    					if(xDiff < 0)
    						mTouchDownX = mTouchDownX - mTouchSlop;
    					else
    						mTouchDownX = mTouchDownX + mTouchSlop;
    					xDiff = (int) (x - mTouchDownX);
    				}
    				
    				if (mTouchState == TOUCH_STATE_SCROLLING) {
    					mPositionDelta = -(float)xDiff / (mWidth * mNumVirtualScreens);
    					
    				}
    				break;
    				
    			case MotionEvent.ACTION_UP:
    				if (mTouchState == TOUCH_STATE_SCROLLING) {
    					final VelocityTracker velocityTracker = mVelocityTracker;
    					velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
    					float velocityX = velocityTracker.getXVelocity() / (float)(mNumVirtualScreens * mWidth);
    					
    					mPosition =  mPosition + mPositionDelta;
    					mPositionDelta = 0;
    					
    					if(!returnSpring()) {
    						mVelocity = Math.min(3, Math.abs(velocityX * mNumVirtualScreens)) ;
    						// deaccelerate();
    						// Inertion
    						if(Math.abs(velocityX) * (float)(mNumVirtualScreens * mWidth) > SNAP_VELOCITY)
    							moveToPosition(mPosition, mPosition - (velocityX > 0 ? 1 : -1) * 1 / (float) mNumVirtualScreens );
    						else
    							moveToPosition(mPosition, mPosition - 0.7f * velocityX * ((float)SCROLLING_TIME / 1000) );						
    					}					
    				}				
    				mTouchState = TOUCH_STATE_REST;
    				break;
    				
    			case MotionEvent.ACTION_CANCEL:
    				mTouchState = TOUCH_STATE_REST;
    				mPositionDelta = 0;
    				break;
    		}
    		dispatchMoving();
    	}
    	
    	private boolean returnSpring() {
    		mVelocity = 0;
    		if(mPositionDelta + mPosition > 1 - 0.5 / (float) mNumVirtualScreens)
    			moveToPosition(mPosition, (float) (1 - 0.5 / (float) mNumVirtualScreens));
    		else if(mPositionDelta + mPosition < 0.5 / (float) mNumVirtualScreens)
    			moveToPosition(mPosition, (float) 0.5 / (float) mNumVirtualScreens);
    		else
    			return false;
    		return true;
    	}
    	
    	private void moveToPosition(float current_position, float desired_position) {
    		mScroller.startScroll((int)(current_position * 1000), 0, (int)((desired_position - current_position) * 1000), 0, SCROLLING_TIME);
    		mHandler.postDelayed(mRunnable, 20);
    	}
    	
    	private boolean onMovingToPosition() {
    		if(mScroller.computeScrollOffset()) {
    			mPosition = (float)mScroller.getCurrX() / 1000;
    			dispatchMoving();
    			return true;
    		} else {
    			returnSpring();
    			return false;
    		}
    	}
    	
    	private float normalizePosition(float xOffset) {
    		final float springZone = 1 / (float) mNumVirtualScreens;
    		// Normalized offset is from 0 to 0.5
    		float xOffsetNormalized = Math.abs(xOffset - 0.5f);
    		if(xOffsetNormalized + springZone / 2 > 0.5f) {
    			// Spring formula
    			// (0.5 - 2 * (1 - (x / (2 * springZone) + 0.5))^2) * springZone
    			// where x >=0 and <= springZone
    			// delta y = springZone / 2, y >=0 and y <= springZone / 2
    			xOffsetNormalized = 0.5f - springZone / 2 + 
    					(0.5f - 2 * (float)Math.pow( (double)(1 - ( (xOffsetNormalized - 0.5f + springZone / 2) / (2 * springZone) + 0.5)), 2 ) ) * springZone;
    			
    			if(xOffset < 0.5f)
    				xOffset = 0.5f - xOffsetNormalized;  
    			else
    				xOffset = 0.5f + xOffsetNormalized;
    		}		
    		return xOffset;
    	}
    	
    	public synchronized void addMovingListener(ZTouchMoveListener listener) {
    		mListeners.add(listener);
    	}
    	
    	private synchronized void dispatchMoving() {
    		Iterator<ZTouchMoveListener> iterator = mListeners.iterator();
    		while(iterator.hasNext())  {
    			((ZTouchMoveListener) iterator.next()).onTouchOffsetChanged(normalizePosition(mPosition + mPositionDelta));
    		}
    	}
    }
    

    Сразу хочу оговориться, что код не претендует на супер чистоту и порядок, для меня было главным, чтобы он выполнял свою задачу, на прическу не было времени.

    Класс ZTouchMove имеет метод onTouchEvent(MotionEvent e), как вход, который вызывается из onTouchEvent класса WallpaperService.Engine. Далее ваш рендерер должен реализовать интерфейс ZTouchMoveListener, с методом onTouchOffsetChanged(float xOffset), который в свою очередь будет принимать результат в привычном формате от 0 до 1.

    Так же необходимо произвести начальную инициализацию ZTouchMove путем вызова метода init(Context ctx), передав в него контекст приложения. Это необходимо для определения ширины экрана и некоторых других параметров. А так же зарегистрировать рендерер в качестве слушателя событий:
    mTouchMove = new ZTouchMove();
    mTouchMove.init(ctx);
    mTouchMove.addMovingListener(mRenderer);
    


    Так как я не нашел способа определения количества виртуальных рабочих столов, этот параметр был захардкодан в переменной mNumVirtualScreens. При желании можно дописать метод для его изменения и использовать на свое усмотрение.

    Особенности реализации анимации и инерции класса ZTouchMove: при медленных перемещениях срабатывает «инерция», при быстрых срабатывает «доводчик» до следующего виртуального рабочего стола. На крайних положениях работает «пружина».

    Из недостатков такого метода стоит отметить несинхронность работы перемещения рабочего стола и обоев. То есть может так случиться, что рабочий стол уже «уперся» в крайнее положение, а обои все еще можно двигать. Или на рабочем столе при определенной скорости сработает «доводчик» на соседний экран, а «доводчик» обоев может не сработать. Данные эффекты исключить не предоставляется возможным, так как у нас в принципе отсутствует информация о текущем положении рабочего стола.

    Гибридное решение


    Пользователь сам будет выбирать метод работы «параллакса» в настройках, или же можно автоматически определять работает ли стандартный метод, и если нет, переключать на ZTouchMove. Вот реализация автоматического определения:

    if(xOffset != 0 && xOffset != 0.5f && xOffset != 1 || mOffsetChangedEnabled) {
       	mOffsetChangedEnabled = true;
        	mXPos = xOffset - 0.5f;
    	// Устанавливаем положение камеры
    	setupLookatM();
    }
    


    Оно основано на том, что xOffset при стандартной реализации не принимает значений отличных от 0, 0.5 и 1, в случае если стандартный метод onOffsetsChanged класса WallpaperService.Engine не работает правильно. Соответственно флаг mOffsetChangedEnabled по умолчанию равен false, и означает, что должен работать класс ZTouchMove.

    Лично я выбрал гибридную настройку, где по умолчанию работает автоматическое определение, и есть еще две опции: «Режим рабочего стола» и «Режим прикосновения».

    Update: Видео работы двух методов реализации.

    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 17

      +5
      Хотелось бы увидеть пример работы на видео. Если возможно.
        0
        Да, видео завтра обязательно добавлю, сегодня уже глаза закрываются.
        +1
        Добавил видео. Если что-то не так сильно не ругайте, это мое первое видео.
          0
          А что у Вас за тема стоит? и доп панель с dropbox? Часы? и как сделали полупрозрачную верхнюю панель?
            0
            Тема стандартная HTC, dropbox идет предустановленным, но я, если честно, им не пользуюсь. Часы и верхняя панель тоже все стандартное.
            0
            Было бы полезно в самое начало статьи вставить пару картинок, просто-и-понятно демонстрирующих/напоминающих, что же такое параллакс-эффект.
            Уверен, не все читающие знают/помнят.
              0
              Я думал над этим, но вот что то идеи, как можно изобразить на картинке параллакс эффект, который является анимацией, не нашел. Если у вас есть конкретные примеры, с удовольствием добавлю.
                0
                Два скриншота из DOOM: узкий корридор, на одном игрок стоит у левой стены, потом у правой :)
              0
              Было бы неплохо увидеть побольше комментариев в коде и разъяснения, что за «Spring formula» и откуда она была выведена.
                0
                Ох, spring formula была выведена в муках и воспоминаниях основ высшей математики. По сути она переводит координаты реального смещения экрана (пальцем) в координаты смещения фона с учетом функции пружины.

                Простым языком, например, мы сдвигаем палец на 0.3 единицы, а фон уходит на 0.2 единицы. Если пружина не действует, то смещение на 0.3 единицы пальцем будет соответствовать смещению фона тоже на 0.3 единицы.

                В начале действия пружины расхождение в смещениях фона и пальца небольшие и по мере «сжатия» пружины разница между смещениями увеличивается вплоть до того, что фон перестает двигаться при движении пальца (пружина полностью сжата). С математической точки зрения формула пружины, это перевернутая порабола(далее просто порабола), при чем точкой начала «действия» этой пораболы(пружины), является точка в которой производная пораболы равна единице, чтобы при «стыковке» с линейной функцией, которая действует до пружины получалась гладкая функция, если этого не сделать, то получим рывок при переходе из обычного режима в режим пружины. Так же значение самой функции пораболы должно точно соответствовать линейной функции обычного движения в точке начала действия пружины, то есть в точке сопряжения этих функций.

                В методе NormalizePosition происходит как раз это сопряжение и переключение с линейной функции на пораболическую в точке начала действия пружины stringZone, которая в свою очередь начинается на последнем виртуальном скрине. А жесткая формула, которая была там указана, это формула перевернутой пораболы, которая сдвинута по осям x y и сжата по y так, чтобы соответствовать вышеуказанным ограничениям.

                Таким образом, мы получаем гладкую S образную функцию на промежутке входных значений от 0 до 1.

                Ух, наверное ничего не понятно, но это был один из самых сложных участков реализации. Еще есть формула инерции f(x) = ax^3 + bx^2 + cx + d, которая по сути является аппроксимирующей функцией третьего порядка нашего движения, которую мы «подстраиваем» под скорость движения нашего пальца в момент отрыва от экрана, а дальше начиная с этой скорости функция автоматом вычисляет положение экрана плавно сводя эту скорость к нулю.

                Если что-то не понятно задавайте вопросы попробую прояснить :)
                0
                > это формула перевернутой пораболы, которая сдвинута по осям x y и сжата по y так, чтобы соответствовать вышеуказанным ограничениям.
                Вот это не вполне ясно. Где там перевернутая парабола? Как понимаю, коэффициенты и общий вид ее значительно изменен, эти вычитания из 1 и 0,5 тоже неясны.
                  0
                  Я же говорю, формула выводилась в муках :). Если воспользоваться сервисом bit.ly/TQWFkI в нем, можно увидеть, что это перевернутая порабола. Но естественно не вся порабола используется в качестве рабочего участка, а только от 0 до 0.2.

                  В частном случае я взял springZone равным 0.2, так как у нас 5 рабочих столов (всю шкалу изменений от 0 до 1 делим на 5 получаем 0.2). График интересующего нас участка: bit.ly/Uduona. В итоге мы получили функцию, производная которой равна 1 в 0, и производная равна 0 в 0.2 (то есть в конце springZone, «пружина» сжата до максимума).

                  По поводу коэффициентов получившейся формулы я сейчас не могу точно сказать какой из них за что отвечает (напр. за сдвиги и сжатия/расширения), у меня по математике была 4, и это было 6 лет назад, не помню я уже, подбирал полуэкспиремнтально и с листочком бумаги, который к сожалению потерялся. Но при желании можно легко разобраться исходя из начальной y = x^2 и в общем виде введя коэффициенты y = a + b*(x*c + d)^2, с помощью вышеуказанного сервиса увидеть, какой из коэфициентов за что отвечает и сгенерировать нужные для своей цели.
                    0
                    С графиками стало визуально яснее, вам была нужна производная y'(x), вида (в вашем случае) -5x + 1
                    Просто интересно было бы понять, как вы прикидывали эти коэффициенты, чтобы по производной построить параболу.
                      0
                      На самом деле все происходило визуально, нарисовал пораболу, нашел визуально, какой участок меня интересует и потом методом тыка пробовал подбирать коэффиенты, которые «установили» бы этот участок в нужный мне диапазон за основу брал формулу y=a + b*(c*x +d)^2, и потом методом подбора коэффициентов нашел искомые мне значения. Если честно уже смутно помню, как именно дошел до результата.

                      Правильный способ взять за основу формулу y=a*x^2+b*x+c (взято отсюда http://bit.ly/WNJr58) найти производную равную 2*a*x+b. Принять, что производная в 0 равна 1, а в точке максимального сжатия S равна 0, и так же сама функция пораболы равна 0 в точке 0. Итого получаем систему из трех уравнений:
                      b=1
                      2aS+b=0
                      c=0
                      Откуда легко выражает формулу «пружины» y = (-1/(2S)) * x^2 + x, где S это springZone. (http://bit.ly/W08LWm — график по новому методу, ничем не отличается от изначального).

                      Вывод: надо было хорошо учить математику, и не идти сразу «визуальным» способом, а найти решение за 5 минут чисто математически. Хотя с другой стороны (оправдывая себя :) система образования могла быть построена на более прикладном уровне и сразу рассказывать, для чего и где мы можем использовать пораболы, гиперболы, производные, апроксимацию и прочие полезные вещи.
                        0
                        Вот меня тоже заинтересовал скорее подход, поскольку я догадывался, что можно привести к уравнениям, но пошел неверным путем, рассчитывая коэффициенты a и b из графика и пытаясь их сопоставить с графиком и производной.
                        Большое спасибо! Остался последний вопрос — из каких соображений рассчитывается xOffsetNormalized и как он мог бы быть рассчитан исходя из измененной формулы?
                          0
                          xOffsetNormalized имеет диапазон от 0 до 0.5, и равен Math.abs(xOffset — 0.5f), то есть мы приводим диапазон движений от 0 до 1 к диапазону от 0 до 0.5, но без информации в какую сторону от середины (от позиции 0.5) мы двигаемся.

                          Дальше работает наша функция пружины, которая не важно по какому методу посчитана (результат то один и тот же). И потом мы восстанавливаем информацию о направлении движения с помощью конструкций

                          if(xOffset < 0.5f)
                          xOffset = 0.5f — xOffsetNormalized;
                          else
                          xOffset = 0.5f + xOffsetNormalized;

                          Таким образом, мы применяем нашу пружину к нормализованному значению входного параметра, и на обоих концах входного параметра пружина работает одинаково (то есть от 0 до 0.2 и от 0.8 до 1).

                          Математически мы просто «отзеркалили» функцию нормализованного значения относительно осей XY. Получив в итоге S образную функцию от 0 до 1, которая имеет линейный вид в диапазоне от 0.2 до 0.8 и «загнутый» (пораболлический) на краях.
                  0
                  .

                  Only users with full accounts can post comments. Log in, please.