From e2e24317ca31f6e07371f330a639bb48dfac89d0 Mon Sep 17 00:00:00 2001 From: nivl4 Date: Mon, 23 May 2016 13:00:16 +0800 Subject: [PATCH] feat: add GIF supported AvatarView --- Habitica/build.gradle | 7 + Habitica/res/values/attrs_avatar_view.xml | 7 + .../android/habitica/ui/AvatarView.java | 452 ++++++++++++++++++ .../lib/models/HabitRPGUser.java | 70 +++ .../habitrpgwrapper/lib/models/Hair.java | 4 + .../habitrpgwrapper/lib/models/Outfit.java | 5 + 6 files changed, 545 insertions(+) create mode 100644 Habitica/res/values/attrs_avatar_view.xml create mode 100644 Habitica/src/main/java/com/habitrpg/android/habitica/ui/AvatarView.java diff --git a/Habitica/build.gradle b/Habitica/build.gradle index 3c0d94e5f..5a50b6d4d 100644 --- a/Habitica/build.gradle +++ b/Habitica/build.gradle @@ -117,6 +117,13 @@ dependencies { //Analytics compile 'com.amplitude:android-sdk:2.7.1' + // Fresco Image Management Library + compile('com.facebook.fresco:fresco:0.10.0') { + exclude module: 'bolts-android' + } + compile('com.facebook.fresco:animated-gif:0.10.0') { + exclude module: 'bolts-android' + } //Tests testCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.3.1' testCompile 'org.robolectric:robolectric:3.0' diff --git a/Habitica/res/values/attrs_avatar_view.xml b/Habitica/res/values/attrs_avatar_view.xml new file mode 100644 index 000000000..595b30f9d --- /dev/null +++ b/Habitica/res/values/attrs_avatar_view.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/AvatarView.java b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/AvatarView.java new file mode 100644 index 000000000..957b4978b --- /dev/null +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/AvatarView.java @@ -0,0 +1,452 @@ +package com.habitrpg.android.habitica.ui; + +import com.facebook.drawee.backends.pipeline.Fresco; +import com.facebook.drawee.controller.BaseControllerListener; +import com.facebook.drawee.generic.GenericDraweeHierarchy; +import com.facebook.drawee.generic.GenericDraweeHierarchyBuilder; +import com.facebook.drawee.interfaces.DraweeController; +import com.facebook.drawee.view.DraweeHolder; +import com.facebook.drawee.view.MultiDraweeHolder; +import com.facebook.imagepipeline.image.ImageInfo; +import com.habitrpg.android.habitica.R; +import com.magicmicky.habitrpgwrapper.lib.models.HabitRPGUser; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.PointF; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.Animatable; +import android.graphics.drawable.Drawable; +import android.support.annotation.NonNull; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +public class AvatarView extends View { + public static final String IMAGE_URI_ROOT = "https://habitica-assets.s3.amazonaws.com/mobileApp/images/"; + private static final String TAG = "AvatarView"; + private static final Map sFilenameMap; + private static final Rect sFullHeroRect = new Rect(0, 0, 140, 147); + private static final Rect sCompactHeroRect = new Rect(0, 0, 114, 114); + private static final Rect sHeroOnlyRect = new Rect(0, 0, 90, 90); + + static { + Map tempMap = new HashMap<>(); + tempMap.put("head_special_1", "ContributorOnly-Equip-CrystalHelmet.gif"); + tempMap.put("armor_special_1", "ContributorOnly-Equip-CrystalArmor.gif"); + tempMap.put("weapon_special_critical", "weapon_special_critical.gif"); + tempMap.put("Pet-Wolf-Cerberus", "Pet-Wolf-Cerberus.gif"); + sFilenameMap = Collections.unmodifiableMap(tempMap); + } + + private boolean mShowBackground = true; + private boolean mShowMount = true; + private boolean mShowPet = true; + private boolean mHasBackground; + private boolean mHasMount; + private boolean mHasPet; + private boolean mIsOrphan; + private MultiDraweeHolder mMultiDraweeHolder = new MultiDraweeHolder<>(); + private HabitRPGUser mUser; + private RectF mAvatarRectF; + private Matrix mMatrix = new Matrix(); + private AtomicInteger mNumberLayersInProcess = new AtomicInteger(0); + private Consumer mAvatarImageConsumer; + private Bitmap mAvatarBitmap; + private Canvas mAvatarCanvas; + + public AvatarView(Context context) { + super(context); + init(null, 0); + } + + public AvatarView(Context context, AttributeSet attrs) { + super(context, attrs); + init(attrs, 0); + } + + public AvatarView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(attrs, defStyle); + } + + public AvatarView(Context context, boolean showBackground, boolean showMount, boolean showPet) { + super(context); + + mShowBackground = showBackground; + mShowMount = showMount; + mShowPet = showPet; + mIsOrphan = true; + } + + private void init(AttributeSet attrs, int defStyle) { + // Load attributes + final TypedArray a = getContext().obtainStyledAttributes( + attrs, R.styleable.AvatarView, defStyle, 0); + + try { + mShowBackground = a.getBoolean(R.styleable.AvatarView_showBackground, true); + mShowMount = a.getBoolean(R.styleable.AvatarView_showMount, true); + mShowPet = a.getBoolean(R.styleable.AvatarView_showPet, true); + } finally { + a.recycle(); + } + } + + private void showLayers(@NonNull Map layerMap) { + if (mMultiDraweeHolder.size() > 0) return; + int i = 0; + + mNumberLayersInProcess.set(layerMap.size()); + + for (Map.Entry entry : layerMap.entrySet()) { + final LayerType layerKey = entry.getKey(); + final String layerName = entry.getValue(); + final int layerNumber = i++; + + GenericDraweeHierarchy hierarchy = new GenericDraweeHierarchyBuilder(getResources()) + .setFadeDuration(0) + .build(); + + DraweeHolder draweeHolder = DraweeHolder.create(hierarchy, getContext()); + draweeHolder.getTopLevelDrawable().setCallback(this); + mMultiDraweeHolder.add(draweeHolder); + + DraweeController controller = Fresco.newDraweeControllerBuilder() + .setUri(IMAGE_URI_ROOT + getFileName(layerName)) + .setControllerListener(new BaseControllerListener() { + @Override + public void onFinalImageSet( + String id, + ImageInfo imageInfo, + Animatable anim) { + if (imageInfo != null) { + mMultiDraweeHolder.get(layerNumber).getTopLevelDrawable().setBounds(getLayerBounds(layerKey, layerName, imageInfo)); + onLayerComplete(); + } + } + + @Override + public void onFailure(String id, Throwable throwable) { + Log.e(TAG, "Error loading layer: " + layerName, throwable); + onLayerComplete(); + } + }) + .setAutoPlayAnimations(!mIsOrphan) + .build(); + draweeHolder.setController(controller); + } + + if (mIsOrphan) mMultiDraweeHolder.onAttach(); + } + + private Map getLayerMap() { + assert mUser != null; + return getLayerMap(mUser, true); + } + + private Map getLayerMap(@NonNull HabitRPGUser user, boolean resetHasAttributes) { + EnumMap layerMap = user.getAvatarLayerMap(); + + if (resetHasAttributes) mHasBackground = mHasMount = mHasPet = false; + + String mountName = user.getItems().getCurrentMount(); + if (mShowMount && !TextUtils.isEmpty(mountName)) { + layerMap.put(LayerType.MOUNT_BODY, "Mount_Body_" + mountName); + layerMap.put(LayerType.MOUNT_HEAD, "Mount_Head_" + mountName); + if (resetHasAttributes) mHasMount = true; + } + + String petName = user.getItems().getCurrentPet(); + if (mShowPet && !TextUtils.isEmpty(petName)) { + layerMap.put(LayerType.PET, "Pet-" + petName); + if (resetHasAttributes) mHasPet = true; + } + + String backgroundName = user.getPreferences().getBackground(); + if (mShowBackground && !TextUtils.isEmpty(backgroundName)) { + layerMap.put(LayerType.BACKGROUND, "background_" + backgroundName); + if (resetHasAttributes) mHasBackground = true; + } + return layerMap; + } + + private Rect getLayerBounds(@NonNull LayerType layerType, @NonNull String layerName, @NonNull ImageInfo layerImageInfo) { + PointF offset = null; + Rect bounds = new Rect(0, 0, layerImageInfo.getWidth(), layerImageInfo.getHeight()); + RectF boundsF = new RectF(bounds); + + // lookup layer specific offset + switch (layerName) { + case "weapon_special_critical": + if (mShowMount || mShowPet) { + // full hero box + if (mHasMount) { + offset = new PointF(13.0f, 12.0f); + } else if (mHasPet) { + offset = new PointF(13.0f, 24.5f + 12.0f); + } else { + offset = new PointF(13.0f, 28.0f + 12.0f); + } + } else if (mShowBackground) { + // compact hero box + offset = new PointF(-12.0f, 18.0f + 12.0f); + } else { + // hero only box + offset = new PointF(-12.0f, 12.0f); + } + break; + default: + break; + } + + // otherwise lookup default layer type based offset + if (offset == null) { + switch (layerType) { + case BACKGROUND: + if (!(mShowMount || mShowPet)) { + offset = new PointF(-25.0f, 0.0f); // compact hero box + } + break; + case MOUNT_BODY: + case MOUNT_HEAD: + offset = new PointF(25.0f, 18.0f); // full hero box + break; + case BACK: + case SKIN: + case SHIRT: + case ARMOR: + case BODY: + case HEAD_0: + case HAIR_BASE: + case HAIR_BANGS: + case HAIR_MUSTACHE: + case HAIR_BEARD: + case EYEWEAR: + case HEAD: + case HEAD_ACCESSORY: + case HAIR_FLOWER: + case SHIELD: + case WEAPON: + case ZZZ: + if (mShowMount || mShowPet) { + // full hero box + if (mHasMount) { + offset = new PointF(25.0f, 0); + } else if (mHasPet) { + offset = new PointF(25.0f, 24.5f); + } else { + offset = new PointF(25.0f, 28.0f); + } + } else if (mShowBackground) { + // compact hero box + offset = new PointF(0.0f, 18.0f); + } + break; + case PET: + offset = new PointF(0, sFullHeroRect.height() - layerImageInfo.getHeight()); + break; + default: + break; + } + } + + if (offset != null) { + Matrix translateMatrix = new Matrix(); + translateMatrix.setTranslate(offset.x, offset.y); + translateMatrix.mapRect(boundsF); + } + + // resize bounds to fit and keep original aspect ratio + mMatrix.mapRect(boundsF); + boundsF.round(bounds); + + return bounds; + } + + private String getFileName(@NonNull String imageName) { + if (sFilenameMap.containsKey(imageName)) { + return sFilenameMap.get(imageName); + } + + return imageName + ".png"; + } + + private void onLayerComplete() { + if (mNumberLayersInProcess.decrementAndGet() == 0) { + if (mAvatarImageConsumer != null) { + mAvatarImageConsumer.accept(getAvatarImage()); + } + } + } + + public void onAvatarImageReady(@NonNull Consumer consumer) { + mAvatarImageConsumer = consumer; + if (mMultiDraweeHolder.size() > 0 && mNumberLayersInProcess.get() == 0) { + mAvatarImageConsumer.accept(getAvatarImage()); + } else { + initAvatarRectMatrix(); + showLayers(getLayerMap()); + } + } + + public void setUser(@NonNull HabitRPGUser user) { + HabitRPGUser oldUser = mUser; + mUser = user; + + if (oldUser != null) { + Map currentLayerMap = getLayerMap(oldUser, false); + Map newLayerMap = getLayerMap(user, false); + if (!currentLayerMap.equals(newLayerMap)) { + mMultiDraweeHolder.clear(); + mNumberLayersInProcess.set(0); + } + } + invalidate(); + } + + private Rect getOriginalRect() { + return (mShowMount || mShowPet) ? sFullHeroRect : ((mShowBackground) ? sCompactHeroRect : sHeroOnlyRect); + } + + private Bitmap getAvatarImage() { + assert mUser != null; + assert mAvatarRectF != null; + Rect canvasRect = new Rect(); + mAvatarRectF.round(canvasRect); + mAvatarBitmap = Bitmap.createBitmap(canvasRect.width(), canvasRect.height(), Bitmap.Config.ARGB_8888); + mAvatarCanvas = new Canvas(mAvatarBitmap); + draw(mAvatarCanvas); + + return mAvatarBitmap; + } + + private void initAvatarRectMatrix() { + if (mAvatarRectF == null) { + Rect srcRect = getOriginalRect(); + + if (mIsOrphan) { + mAvatarRectF = new RectF(srcRect); + + // change scale to not be 1:1 + // a quick fix as fresco AnimatedDrawable/ScaleTypeDrawable + // will not translate matrix properly + mMatrix.setScale(1.2f, 1.2f); + mMatrix.mapRect(mAvatarRectF); + } else { + // full hero box when showMount and showPet is enabled (140w * 147h) + // compact hero box when only showBackground is enabled (114w * 114h) + // hero only box when all show settings disabled (90w * 90h) + mAvatarRectF = new RectF(0, 0, getWidth(), getHeight()); + mMatrix.setRectToRect(new RectF(srcRect), mAvatarRectF, Matrix.ScaleToFit.START); // TODO support other ScaleToFit + mAvatarRectF = new RectF(srcRect); + mMatrix.mapRect(mAvatarRectF); + } + } + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + initAvatarRectMatrix(); + + // draw only when user is set + if (mUser == null) return; + + // request image layers if not yet processed + if (mMultiDraweeHolder.size() == 0) { + showLayers(getLayerMap()); + } + + // manually call onAttach/onDetach if view is without parent as they will never be called otherwise + if (mIsOrphan) mMultiDraweeHolder.onAttach(); + mMultiDraweeHolder.draw(canvas); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mMultiDraweeHolder.onDetach(); + } + + @Override + public void onStartTemporaryDetach() { + super.onStartTemporaryDetach(); + mMultiDraweeHolder.onDetach(); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + mMultiDraweeHolder.onAttach(); + } + + @Override + public void onFinishTemporaryDetach() { + super.onFinishTemporaryDetach(); + mMultiDraweeHolder.onAttach(); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + return mMultiDraweeHolder.onTouchEvent(event) || super.onTouchEvent(event); + } + + @Override + protected boolean verifyDrawable(Drawable who) { + return mMultiDraweeHolder.verifyDrawable(who) || super.verifyDrawable(who); + } + + @Override + public void invalidateDrawable(@NonNull Drawable drawable) { + invalidate(); + if (mAvatarCanvas != null) draw(mAvatarCanvas); + } + + public enum LayerType { + BACKGROUND(0), + MOUNT_BODY(1), + BACK(2), + SKIN(3), + SHIRT(4), + ARMOR(5), + BODY(6), + HEAD_0(7), + HAIR_BASE(8), + HAIR_BANGS(9), + HAIR_MUSTACHE(10), + HAIR_BEARD(11), + EYEWEAR(12), + HEAD(13), + HEAD_ACCESSORY(14), + HAIR_FLOWER(15), + SHIELD(16), + WEAPON(17), + MOUNT_HEAD(18), + ZZZ(19), + PET(20); + + final int order; + + LayerType(int order) { + this.order = order; + } + } + + public interface Consumer { + void accept(T t); + } +} diff --git a/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/HabitRPGUser.java b/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/HabitRPGUser.java index c602d8feb..8eb02b879 100644 --- a/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/HabitRPGUser.java +++ b/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/HabitRPGUser.java @@ -3,6 +3,7 @@ package com.magicmicky.habitrpgwrapper.lib.models; import com.google.gson.annotations.SerializedName; import com.habitrpg.android.habitica.HabitDatabase; +import com.habitrpg.android.habitica.ui.AvatarView; import com.magicmicky.habitrpgwrapper.lib.models.tasks.Task; import com.magicmicky.habitrpgwrapper.lib.models.tasks.TasksOrder; import com.raizlabs.android.dbflow.annotation.Column; @@ -15,8 +16,12 @@ import com.raizlabs.android.dbflow.sql.builder.Condition; import com.raizlabs.android.dbflow.sql.language.Select; import com.raizlabs.android.dbflow.structure.BaseModel; +import android.text.TextUtils; + import java.util.ArrayList; +import java.util.EnumMap; import java.util.List; +import java.util.Map; @Table(databaseName = HabitDatabase.NAME) public class HabitRPGUser extends BaseModel { @@ -365,4 +370,69 @@ public class HabitRPGUser extends BaseModel { return layerNames; } + + public EnumMap getAvatarLayerMap() { + EnumMap layerMap = new EnumMap<>(AvatarView.LayerType.class); + + Preferences prefs = getPreferences(); + Outfit outfit = (prefs.getCostume()) ? getItems().getGear().getCostume() : getItems().getGear().getEquipped(); + + if (outfit != null) { + if (!TextUtils.isEmpty(outfit.getBack())) { + layerMap.put(AvatarView.LayerType.BACK, outfit.getBack()); + } + if (outfit.isAvailable(outfit.getArmor())) { + layerMap.put(AvatarView.LayerType.ARMOR, prefs.getSize() + "_" + outfit.getArmor()); + } + if (outfit.isAvailable(outfit.getBody())) { + layerMap.put(AvatarView.LayerType.BODY, outfit.getBody()); + } + if (outfit.isAvailable(outfit.getEyeWear())) { + layerMap.put(AvatarView.LayerType.EYEWEAR, outfit.getEyeWear()); + } + if (outfit.isAvailable(outfit.getHead())) { + layerMap.put(AvatarView.LayerType.HEAD, outfit.getHead()); + } + if (outfit.isAvailable(outfit.getHeadAccessory())) { + layerMap.put(AvatarView.LayerType.HEAD_ACCESSORY, outfit.getHeadAccessory()); + } + if (outfit.isAvailable(outfit.getShield())) { + layerMap.put(AvatarView.LayerType.SHIELD, outfit.getShield()); + } + if (outfit.isAvailable(outfit.getWeapon())) { + layerMap.put(AvatarView.LayerType.WEAPON, outfit.getWeapon()); + } + } + + layerMap.put(AvatarView.LayerType.SKIN, "skin_" + prefs.getSkin() + ((prefs.getSleep()) ? "_sleep" : "")); + layerMap.put(AvatarView.LayerType.SHIRT, prefs.getSize() + "_shirt_" + prefs.getShirt()); + layerMap.put(AvatarView.LayerType.HEAD_0, "head_0"); + + Hair hair = prefs.getHair(); + if (hair != null) { + String hairColor = hair.getColor(); + + if (hair.isAvailable(hair.getBase())) { + layerMap.put(AvatarView.LayerType.HAIR_BASE, "hair_base_" + hair.getBase() + "_" + hairColor); + } + if (hair.isAvailable(hair.getBangs())) { + layerMap.put(AvatarView.LayerType.HAIR_BANGS, "hair_bangs_" + hair.getBangs() + "_" + hairColor); + } + if (hair.isAvailable(hair.getMustache())) { + layerMap.put(AvatarView.LayerType.HAIR_MUSTACHE, "hair_mustache_" + hair.getMustache() + "_" + hairColor); + } + if (hair.isAvailable(hair.getBeard())) { + layerMap.put(AvatarView.LayerType.HAIR_BEARD, "hair_beard_" + hair.getBeard() + "_" + hairColor); + } + if (hair.isAvailable(hair.getFlower())) { + layerMap.put(AvatarView.LayerType.HAIR_FLOWER, "hair_flower_" + hair.getFlower()); + } + } + + if (prefs.getSleep()) { + layerMap.put(AvatarView.LayerType.ZZZ, "zzz"); + } + + return layerMap; + } } diff --git a/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/Hair.java b/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/Hair.java index 6829241d5..69dc72e6a 100644 --- a/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/Hair.java +++ b/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/Hair.java @@ -75,4 +75,8 @@ public class Hair extends BaseModel { public int getFlower() { return flower; } public void setFlower(int flower) { this.flower = flower; } + + public boolean isAvailable(int hairId) { + return hairId > 0; + } } diff --git a/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/Outfit.java b/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/Outfit.java index ff92c63e7..ba899a95c 100644 --- a/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/Outfit.java +++ b/Habitica/src/main/java/com/magicmicky/habitrpgwrapper/lib/models/Outfit.java @@ -9,6 +9,8 @@ import com.raizlabs.android.dbflow.annotation.PrimaryKey; import com.raizlabs.android.dbflow.annotation.Table; import com.raizlabs.android.dbflow.structure.BaseModel; +import android.text.TextUtils; + /** * Created by viirus on 20/07/15. */ @@ -54,4 +56,7 @@ public class Outfit extends BaseModel { public String getWeapon() {return weapon;} public void setWeapon(String weapon) {this.weapon = weapon;} + public boolean isAvailable(String outfit) { + return !TextUtils.isEmpty(outfit) && !outfit.endsWith("base_0"); + } }