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

от автора

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

Многим пользователям смартфонов 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. Можете убедиться в этом на видео:

ссылка на оригинал статьи http://habrahabr.ru/post/232477/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *