Pull to refresh

Photo Widget своими руками

Reading time11 min
Views7.9K


Привет, уважаемое хабрасообщество. Моя предыдущая статья про кастомный экран разблокировки получила мало отзывов в виде комментариев, но тем не менее сто человек сохранило её в избранных, тем самым вдохновив меня на написание ещё одной статьи на непопулярную тему.

Многим пользователям смартфонов Xperia нравится красивый трёхмерный стандартный виджет фотографий. С точки зрения терминологии андроид, это не AppWidget, а простой View, очень похожий на виджет. Его можно с большой натяжкой назвать «плагином» к стандартному лаунчеру Xperia Home, поэтому в списке виджетов других лаунчеров его нет. В этом посте я расскажу, как можно сделать похожий виджет.

Введение


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

Основа виджета


Первым этапом необходимо сделать так, чтобы в списке доступных виджетов появился наш (напомню, не виджет). В Sony Ericsson поступили просто — стандартный лаунчер из списка установленных в системе пакетов выбирает те, что начинаются на «com.sonyericsson.advancedwidget» и добавляет название и иконку в список виджетов. Данный способ простой, но у него есть недостаток: один apk — один виджет. Мы поступим умнее (я надеюсь) — в манифесте нашего будущего виджета напишем так:

<receiver android:name=".PhotoWidgetReceiver" >
	<intent-filter>
		<action android:name="by.arriva.ADVANCED_WIDGET" />
	</intent-filter>
</receiver>

Класс PhotoWidgetReceiver оставим пустым:

public class PhotoWidgetReceiver extends BroadcastReceiver {
	
	@Override
	public void onReceive(Context context, Intent intent) {	
	}
}

Благодаря этому трюку наш лаунчер найдет все BroadcastReciever'ы, отвечающие на «by.arriva.ADVANCED_WIDGET». Далее приведен листинг кода из лаунчера.

final PackageManager manager = getPackageManager();
Intent i = new Intent("by.arriva.ADVANCED_WIDGET");
List<ResolveInfo> lri = manager.queryBroadcastReceivers(i, 0);
for(ResolveInfo ri : lri){
	String packageName = ri.activityInfo.packageName;
	String className = ri.activityInfo.name;
	className = className.replace("Receiver", "");
	AdvancedWidgetInfo awi = new AdvancedWidgetInfo(this, packageName, className);
	if(!awi.isValid) continue;
		try {
			WidgetInfo wi = new WidgetInfo(true, awi.label, awi.width+" x "+awi.height, manager.getResourcesForApplication(packageName).getDrawable(awi.preview), new ComponentName(packageName, className));
			widgetsSorted.add(wi);
		} catch (Exception e) {}
		awi = null;
	}
lri.clear();

String className — имя класса, унаследованного от BroadcastReciever, в нашем случае это PhotoWidgetReceiver. Лаунчер ищет в виджете класс PhotoWidget, в котором находится описание виджета. Вот его содержание:

public class PhotoWidget {
	
	public static View getView(Context context){
		WidgetView wv = new WidgetView(context);
		return wv;
	}
	
	public static int getCellWidth(Context context){
		return 2;
	}
	
	public static int getCellHeight(Context context){
		return 2;
	}
	
	public static String getLabel(Context context){
		return context.getString(R.string.app_name);
	}
	
	public static int getIconId(Context context){
		return R.drawable.icon;
	}
	
	public static int getPreviewId(Context context){
		return R.drawable.preview;
	}
}

Структура этого класса должна быть строго постоянной во всех ваших нестандартных (advanced) виджетах, т.к. такая же структура методов и в классе AdvancedWidgetInfo вашего лаунчера.

Непосредственно View


Основной класс нашего виджета
public class WidgetView extends View {
	
	ContentObserver observer;
	GestureDetector gd;
	Scroller scroller;
	NinePatchDrawable frame;
	Bitmap loading;
	Bitmap broken;
	Item[] items = null;
	float touchLastY = 0;
	float touchDownY = 0;
	boolean touchTap = false;
	boolean touchScroll = false;
	long loaderId = -1;
	int getScrollY = 0;
	int width = 160;
	int height = 200;
	
	public WidgetView(Context context) {
		super(context);
		gd = new GestureDetector(context, new GestureListener());
		scroller = new Scroller(context);
		observer = new ContentObserver(new Handler()){
			@Override
			public void onChange(boolean selfChange){
				super.onChange(selfChange);
				loadThumbnails();
			}
		};
		frame = (NinePatchDrawable)getResources().getDrawable(R.drawable.frame);
		loading = getBmp(R.drawable.loading);
		broken = getBmp(R.drawable.broken);
	}
	
	@Override
	public void onAttachedToWindow(){
		super.onAttachedToWindow();
		loadThumbnails();
		getContext().getContentResolver().registerContentObserver(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, observer);
		getContext().getContentResolver().registerContentObserver(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true, observer);
	}
	
	@Override
	public void onDetachedFromWindow(){
		super.onDetachedFromWindow();
		getContext().getContentResolver().unregisterContentObserver(observer);
	}
	
	@Override
	protected void onDraw(Canvas canvas){
		super.onDraw(canvas);
		Paint p = new Paint();
		p.setDither(true);
		p.setFilterBitmap(true);
		int pos = getSelected();
		float percent = getSelectedPercent();
		if(items != null){
			if(items.length == 0){
				Bitmap camera = getBmp(R.drawable.camera);
				Transformation transform = new Transformation(0, percent, 160, 140);
				p.setAlpha(transform.alpha);
				canvas.drawBitmap(camera, transform.matrix3d, p);
				return;
			}
			if(pos >= 1){
				if(items[pos-1] != null){
					if(items[pos-1].thumbnail != null){
						Bitmap thumbnail = items[pos-1].thumbnail;
						Transformation transform = new Transformation(-1, percent, thumbnail.getWidth(), thumbnail.getHeight());
						p.setAlpha(transform.alpha);
						canvas.drawBitmap(thumbnail, transform.matrix3d, p);
					} else {
						Transformation transform = new Transformation(-1, percent, broken.getWidth(), broken.getHeight());
						p.setAlpha(transform.alpha);
						canvas.drawBitmap(broken, transform.matrix3d, p);
					}
				} else {
					Transformation transform = new Transformation(-1, percent, loading.getWidth(), loading.getHeight());
					p.setAlpha(transform.alpha);
					canvas.drawBitmap(loading, transform.matrix3d, p);
				}
			}
			if(items.length-1 >= pos+1){
				if(items[pos+1] != null){
					if(items[pos+1].thumbnail != null){
						Bitmap thumbnail = items[pos+1].thumbnail;
						Transformation transform = new Transformation(1, percent, thumbnail.getWidth(), thumbnail.getHeight());
						p.setAlpha(transform.alpha);
						canvas.drawBitmap(thumbnail, transform.matrix3d, p);
					} else {
						Transformation transform = new Transformation(1, percent, broken.getWidth(), broken.getHeight());
						p.setAlpha(transform.alpha);
						canvas.drawBitmap(broken, transform.matrix3d, p);
					}
				} else {
					Transformation transform = new Transformation(1, percent, loading.getWidth(), loading.getHeight());
					p.setAlpha(transform.alpha);
					canvas.drawBitmap(loading, transform.matrix3d, p);
				}
			}
			if(items.length-1 >= pos){
				if(items[pos] != null){
					if(items[pos].thumbnail != null){
						Bitmap thumbnail = items[pos].thumbnail;
						Transformation transform = new Transformation(0, percent, thumbnail.getWidth(), thumbnail.getHeight());
						p.setAlpha(transform.alpha);
						canvas.drawBitmap(thumbnail, transform.matrix3d, p);
					} else {
						Transformation transform = new Transformation(0, percent, broken.getWidth(), broken.getHeight());
						p.setAlpha(transform.alpha);
						canvas.drawBitmap(broken, transform.matrix3d, p);
					}
				} else {
					Transformation transform = new Transformation(0, percent, loading.getWidth(), loading.getHeight());
					p.setAlpha(transform.alpha);
					canvas.drawBitmap(loading, transform.matrix3d, p);
				}
			}
		} else {
			Transformation transform = new Transformation(0, 0, loading.getWidth(), loading.getHeight());
			canvas.drawBitmap(loading, transform.matrix3d, null);
		}
	}
	
	@Override
	public boolean onTouchEvent(MotionEvent me) {
		if(items == null) return true;
		gd.onTouchEvent(me);
		float touchY = me.getY();
		if(me.getAction() == MotionEvent.ACTION_DOWN){
			if(!scroller.isFinished()){
				scroller.abortAnimation();
			} else {
				touchTap = true;
				touchScroll = false;
			}
			touchDownY = touchLastY = touchY;
		} else if(me.getAction() == MotionEvent.ACTION_MOVE){
			if(Math.abs(touchY - touchDownY) > ViewConfiguration.getTouchSlop() || touchScroll){
				getParent().requestDisallowInterceptTouchEvent(true);
				touchTap = false;
				touchScroll = true;
				getScrollY += -(int)(touchY - touchLastY);
				computeOverscroll();
			}
			touchLastY = touchY;
		} else if((me.getAction() == MotionEvent.ACTION_UP || me.getAction() == MotionEvent.ACTION_CANCEL) && scroller.isFinished()){
			if(touchTap && me.getAction() == MotionEvent.ACTION_UP){
				tap();
			}
			setSelected(getSelected(), true);
			getParent().requestDisallowInterceptTouchEvent(false);
		}
		return true;
	}
	
	@Override
	public void computeScroll(){
		if(scroller.computeScrollOffset()){
			int oldY = getScrollY;
			getScrollY = scroller.getCurrY();
			if(oldY != getScrollY){
				onScrollChanged(0, getScrollY, 0, oldY);
			}
			if(scroller.getFinalY() == getScrollY){
				scroller.abortAnimation();
				setSelected(getSelected(), true);
			}
			postInvalidate();
		}
	}
	
	@Override
	protected void onMeasure(int wms, int hms){
		width = MeasureSpec.getSize(wms);
		height = MeasureSpec.getSize(hms);
		super.onMeasure(wms, hms);
	}
	
	@Override
	protected void onSizeChanged(int w, int h, int oldw, int oldh){
		width = w;
		height = h;
		super.onSizeChanged(w, h, oldw, oldh);
	}
	
	void loadThumbnails(){
		items = null;
		getScrollY = 0;
		invalidate();
		Thread loader = new Thread(new Runnable() {
			public void run() {
				long currentId = Thread.currentThread().getId();
				try{
					final ArrayList<MediaItem> list = mediaList();
					if(list == null || loaderId != currentId) return;
					items = new Item[list.size()];
					postInvalidate();
					for(int i=0; i<list.size(); i++){
 						if(items == null || loaderId != currentId) return;
						items[i] = new Item(list.get(i).path, getBmp(list.get(i)), list.get(i).type);
						postInvalidate();
					}
				} catch(Exception e){}
			}
		});
		loaderId = loader.getId();
		loader.start();
	}
	
	int getSelected(){
		return Math.round(getScrollY/140f);
	}
	
	float getSelectedPercent(){
		float f = getScrollY/140f;
		return f-getSelected();
	}
	
	void setSelected(int pos, boolean anim){
		if(anim && getScrollY!=pos*140){
			scroller.startScroll(0, getScrollY, 0, pos*140-getScrollY, 250);
		} else {
			getScrollY = pos*140;
		}
		invalidate();
	}
	
	void computeOverscroll(){
		if(getScrollY < -50){
			getScrollY = -50;
		} else if(getScrollY > Math.max(items.length-1, 0)*140+50){
			getScrollY = Math.max(items.length-1, 0)*140+50;
		}
		invalidate();
	}
	
	void tap(){
		try{
			if(items != null){
				if(items.length == 0){
					Intent i = new Intent("android.media.action.IMAGE_CAPTURE");
					i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
					getContext().startActivity(i);
					return;
				}
				if(items[getSelected()] == null) return;
				int type = items[getSelected()].type;
				String path = items[getSelected()].path;
				if(type == 1){
					Intent view = new Intent(android.content.Intent.ACTION_VIEW);
	        		File imageFile = new File(path);
	        		view.setDataAndType(Uri.fromFile(imageFile), "image/*");
	        		view.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
	        		getContext().startActivity(view);
				} else if(type == 2){
					Intent view = new Intent(android.content.Intent.ACTION_VIEW);
	        		File imageFile = new File(path);
	        		view.setDataAndType(Uri.fromFile(imageFile), "video/*");
	        		view.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
	        		getContext().startActivity(view);
				}
			}
		} catch(Exception e){}
	}
	
	ArrayList<MediaItem> mediaList(){
		try{
			ArrayList<MediaItem> list = new ArrayList<MediaItem>();
			Cursor c = getContext().getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, new String[]{MediaStore.Images.Media.DATA, MediaStore.Images.Media.DATE_ADDED}, null, null, null);
			if(c.moveToFirst()){
				do{
					list.add(new MediaItem(c.getString(c.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)), c.getString(c.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)), 1));
				} while(c.moveToNext());
			}
			c.close();
			c = getContext().getContentResolver().query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, new String[]{MediaStore.Video.Media.DATA, MediaStore.Video.Media.DATE_ADDED}, null, null, null);
			if(c.moveToFirst()){
				do{
					list.add(new MediaItem(c.getString(c.getColumnIndexOrThrow(MediaStore.Video.Media.DATA)), c.getString(c.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_ADDED)), 2));
				} while(c.moveToNext());
			}
			c.close();
			Collections.sort(list, new Comparator<MediaItem>(){
				@Override
				public int compare(MediaItem mi1, MediaItem mi2) {
					return mi1.date.compareToIgnoreCase(mi2.date);
				}
			});
			Collections.reverse(list);
			return list;
		} catch(Exception e){
			return new ArrayList<MediaItem>();
		}
	}
	
	Bitmap getBmp(MediaItem mi){
		try{
			Paint p = new Paint();
			p.setDither(true);
			p.setFilterBitmap(true);
			if(mi.type == 1){
				int rot = 0;
				try{
					ExifInterface rote = new ExifInterface(mi.path);
					int r = rote.getAttributeInt(ExifInterface.TAG_ORIENTATION, 1);
					if(r == 3){
						rot = 180;
					} else if(r == 8){
						rot = 270;
					} else if(r == 6){
						rot = 90;
					} else {
						rot = 0;
					}
				} catch(Exception e){}
				BitmapFactory.Options bfo = new BitmapFactory.Options();
				bfo.inJustDecodeBounds = true;
				BitmapFactory.decodeFile(mi.path, bfo);
				if(rot == 90 || rot == 270){
					bfo.inSampleSize = Math.min(bfo.outWidth/140, bfo.outHeight/120);
				} else {
					bfo.inSampleSize = Math.max(bfo.outWidth/140, bfo.outHeight/120);
				}
				bfo.inJustDecodeBounds = false;
				Bitmap src = BitmapFactory.decodeFile(mi.path, bfo);
				if(rot != 0){
					Matrix m = new Matrix();
					m.postRotate(rot);
					src = Bitmap.createBitmap(src, 0, 0, src.getWidth(), src.getHeight(), m, true);
				}
				float aspect = (float)src.getWidth()/src.getHeight();
				int image_w = 160;
				int image_h = 140;
				if(aspect > 1){
					image_h = (int)((image_w-20)/aspect+20);
				} else {
					image_w = (int)((image_h-20)*aspect+20);
				}
				Bitmap result = Bitmap.createBitmap(image_w, image_h, Config.ARGB_8888);
				Canvas c = new Canvas(result);
				frame.setBounds(0, 0, image_w, image_h);
				frame.draw(c);
				c.drawBitmap(src, null, new RectF(10, 10, image_w-10, image_h-10), p);
				return result;
			} else if(mi.type == 2){
				Bitmap src = ThumbnailUtils.createVideoThumbnail(mi.path, MediaStore.Video.Thumbnails.MINI_KIND);
				float aspect = (float)src.getWidth()/src.getHeight();
				int image_w = 160;
				int image_h = (int)((image_w-20)/aspect+20);
				Bitmap result = Bitmap.createBitmap(image_w, image_h, Config.ARGB_8888);
				Canvas c = new Canvas(result);
				frame.setBounds(0, 0, image_w, image_h);
				frame.draw(c);
				c.drawBitmap(src, null, new RectF(10, 10, image_w-10, image_h-10), p);
				c.drawBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.video), (image_w-64)/2, (image_h-64)/2, p);
				return result;
			}
		} catch(Exception e){
			return null;
		} catch(OutOfMemoryError e){
			return null;
		}
		return null;
	}
	
	Bitmap getBmp(int res){
		Bitmap bmp = Bitmap.createBitmap(160, 140, Config.ARGB_8888);
		Canvas c = new Canvas(bmp);
		Paint p = new Paint();
		p.setDither(true);
		p.setFilterBitmap(true);
		frame.setBounds(0, 0, 160, 140);
		frame.draw(c);
		c.drawBitmap(BitmapFactory.decodeResource(getResources(), res), 48, 38, p);
		return bmp;
	}
	
	public class MediaItem {
		String path;
		String date;
		int type;
		public MediaItem(String str1, String str2, int i){
			path = str1;
			date = str2;
			type = i;
		}
	}
	
	public class Item {
		String path;
		Bitmap thumbnail;
		int type;
		public Item(String str, Bitmap bmp, int i){
			path = str;
			thumbnail = bmp;
			type = i;
		}
	}
	
	public class Transformation {
		Matrix matrix3d;
		int alpha;
		public Transformation(int pos, float percent, int imageWidth, int imageHeight){
			float centerX = (float)width/2;
			float centerY = (float)height/2;
			float f = -pos + percent;
			centerY -= (float)Math.sin(f*Math.PI/2)*40;
			float f1 = Math.abs(f) - 0.5f;
			if(f1 < 0) f1 = 0;
			float f2 = Math.abs(f) / 1.5f;
			alpha = 255 - (int)(255*f1);
			float scale = (float) (1 - 0.4*f2);
			Camera c = new Camera();
			matrix3d = new Matrix();
			c.save();
			float rotate = percent*100;
			if(pos == 0) rotate *= -1;
			c.rotateX(rotate);
			c.getMatrix(matrix3d);
			matrix3d.preTranslate(-imageWidth/2, -imageHeight/2);
			matrix3d.postScale(scale, scale);
			matrix3d.postTranslate(centerX, centerY);
			c.restore();
		}
	}
	
	private class GestureListener extends GestureDetector.SimpleOnGestureListener {
		@Override
		public boolean onFling(MotionEvent me1, MotionEvent me2, float vX, float vY){
			scroller.fling(0, getScrollY, 0, -(int)vY, 0, 0, -50, Math.max(items.length-1, 0)*140+50);
			invalidate();
			return true;
		}
	}
}


Немного пояснений:

ContentObserver регистрирует все изменения во внешней памяти. Например, при добавлении фотографии метод onChange(boolean selfChange) вызывается несколько раз с интервалом в пару миллисекунд. Метод loadThumbnails() грузит миниатюры всех фотографий в отдельном потоке. Каждый раз при срабатывании метода onChange ContentObserver'а создаётся новый поток для создания миниатюр. Возникает ситуация, когда при добавлении одной фотографии сразу же создаётся одновременно несколько потоков, делающих одно и то же действия. Для того, чтобы избежать этой ситуации в переменной loaderId хранится id последнего созданного потока, а во всех потоках присутствует сравнение сохраненного id и id данного потока. Если цифры не равны, поток уничтожается.

Жесты листания и броска не вызывают перемещение холста View, как это принято. Вместо этого происходит изменение переменной getScrollY. Integer getSelected() возвращает текущую позицию в массиве миниатюр, float getSelectedPercent() возвращает угол наклона миниатюр относительно зрителя. Значение 0,5 соответствует 45 градусам. Возможно, следующая картинка прояснит ситуацию:



В методе onTouchEvent(MotionEvent me) важно не забыть вызывать getParent().requestDisallowInterceptTouchEvent(true) для того, чтобы во время листания миниатюр в виджете не происходило листание столов в лаунчере.

Ну и в конце пару слов о классе Transformation. Он возвращает матрицу трансформации для миниатюр в зависимости от того, что возвращают описанные выше getSelected() и getSelectedPercent().

Ещё следует отметить, что миниатюры всех изображений и видео хранятся в массиве Item[]. Это плохо. Правильным вариантом будет кэширование миниатюр.

Заключение


Вот так кратко я рассказал, как можно сделать симпатичный просмотрщик миниатюр фотографий. Я очень удивлён, что такие красивые трёхмерные эффекты листания изображений абсолютно не тормозят на моём древнем телефоне Samsung Galaxy Gio. Можете убедиться в этом на видео:

Tags:
Hubs:
+12
Comments2

Articles

Change theme settings