Привет, уважаемое читатели. Моя предыдущая статья про кастомный экран разблокировки получила мало отзывов в виде комментариев, но тем не менее сто человек сохранило её в избранных, тем самым вдохновив меня на написание ещё одной статьи на непопулярную тему.
Многим пользователям смартфонов 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. Можете убедиться в этом на видео:
Автор: by_arriva