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

    Similar posts

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

    More
    Ads

    Comments 2

      0
      А готовый apk можно? Для меня все эти java, eclipse, manifest, apk и android sdk — страшнейшие вещи, а виджет понравился. Хотелось бы что-то такое себе для заметок, часто пишу в них какие-то напоминалки, а на стандартные виджеты без слёз не взглянешь…

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