Pull to refresh

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

Reading time 7 min
Views 35K
Каждый, кто пробовал установить себе живые обои, замечал параллакс эффект при перемещении между рабочими столами. Выглядит он очень занимательно, но вот в его реализации возникают проблемы, которые и будут освещены в данной статье. Речь пойдет про реализацию параллакс эффекта под живые обои 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: Видео работы двух методов реализации.

Tags:
Hubs:
+18
Comments 17
Comments Comments 17

Articles