Skip to content

Commit 2321ba1

Browse files
author
farfromrefug
committed
fix(android): much better crossfading between images of different ratio
1 parent 84c48e4 commit 2321ba1

File tree

7 files changed

+226
-61
lines changed

7 files changed

+226
-61
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<template>
2+
<Page>
3+
<ActionBar>
4+
<Label text="CrossFade Test" />
5+
</ActionBar>
6+
7+
<GridLayout rows="*, auto">
8+
<NSImg width="500" height="500" fadeDuration="500" :preventPreClearDrawable="true" alwaysFade="true" :src="src" stretch="aspectFit" backgroundColor="red"/>
9+
<WrapLayout row="1" marginTop="8">
10+
<Button text="start" @tap="start()" />
11+
<Button text="stop" @tap="stop()" />
12+
</WrapLayout>
13+
</GridLayout>
14+
</Page>
15+
</template>
16+
17+
<script lang="ts">
18+
const images = [
19+
'https://plus.unsplash.com/premium_photo-1764446765006-2976436e8ac3?q=80&w=687&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
20+
'https://images.unsplash.com/photo-1764773965937-8c6c0f2ad915?q=80&w=1170&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
21+
'https://images.unsplash.com/photo-1764782979306-1e489462d895?q=80&w=687&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
22+
'https://images.unsplash.com/photo-1764712754802-efe14d4478b6?q=80&w=1964&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D'
23+
];
24+
25+
let testInterval: any;
26+
export default {
27+
data() {
28+
return {
29+
src: images[0]
30+
};
31+
},
32+
methods: {
33+
start() {
34+
if (!testInterval) {
35+
testInterval = setInterval(() => {
36+
const index = images.indexOf(this.src);
37+
console.log('updating src', index, images[(index + 1) % images.length], Date.now());
38+
this.src = images[(index + 1) % images.length];
39+
}, 400);
40+
}
41+
},
42+
stop() {
43+
if (testInterval) {
44+
clearInterval(testInterval);
45+
}
46+
}
47+
}
48+
};
49+
</script>
50+
51+
<style scoped lang="scss">
52+
ActionBar {
53+
background-color: #42b883;
54+
}
55+
.item {
56+
padding: 10;
57+
color: white;
58+
.title {
59+
font-size: 17;
60+
font-weight: bold;
61+
}
62+
.subtitle {
63+
font-size: 14;
64+
}
65+
}
66+
</style>

demo-snippets/vue/install.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import ZoomImage from './ZoomImage.vue';
2121
import ColorFilter from './ColorFilter.vue';
2222
import Failure from './Failure.vue';
2323
import NavigationTest from './NavigationTest.vue';
24+
import CrossFadeTest from './CrossFadeTest.vue';
2425

2526
// Trace.addCategories('NativescriptImage');
2627
// Trace.enable();
@@ -34,6 +35,7 @@ export function installPlugin() {
3435

3536
export const demos = [
3637
{ name: 'Simple', path: 'simple', component: Simple },
38+
{ name: 'CrossFadeTest', path: 'CrossFadeTest', component: CrossFadeTest },
3739
{ name: 'Animated', path: 'animated', component: Animated },
3840
{ name: 'Advanced', path: 'advanced', component: Advanced },
3941
{ name: 'Events', path: 'events', component: Events },

packages/image/platforms/android/java/com/nativescript/image/ConditionalCrossFadeFactory.java

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
import androidx.annotation.NonNull;
55

66
import com.bumptech.glide.load.DataSource;
7-
import com.bumptech.glide.request.transition.DrawableCrossFadeFactory;
87
import com.bumptech.glide.request.transition.NoTransition;
98
import com.bumptech.glide.request.transition.Transition;
9+
import com.bumptech.glide.request.transition.DrawableCrossFadeFactory;
1010

1111
import android.util.Log;
1212
/**
@@ -15,19 +15,27 @@
1515
*/
1616
public class ConditionalCrossFadeFactory extends DrawableCrossFadeFactory {
1717
private final boolean onlyOnNetwork;
18+
private final int duration;
19+
private MatrixPreservingCrossFadeTransition resourceTransition;
1820

19-
public ConditionalCrossFadeFactory(int durationMs, boolean onlyOnNetwork) {
21+
public ConditionalCrossFadeFactory(int duration, boolean onlyOnNetwork) {
2022
// Call the base class constructor which requires (durationMs, crossFadeEnabled)
21-
super(durationMs, true);
23+
super(duration, true);
24+
this.duration = duration;
2225
this.onlyOnNetwork = onlyOnNetwork;
2326
}
24-
27+
private Transition<Drawable> getResourceTransition() {
28+
if (resourceTransition == null) {
29+
resourceTransition = new MatrixPreservingCrossFadeTransition(duration);
30+
}
31+
return resourceTransition;
32+
}
2533
@NonNull
2634
@Override
2735
public Transition<Drawable> build(DataSource dataSource, boolean isFirstResource) {
2836
if (this.onlyOnNetwork && dataSource != DataSource.REMOTE) {
2937
return NoTransition.get();
3038
}
31-
return super.build(DataSource.REMOTE, isFirstResource);
39+
return getResourceTransition();
3240
}
3341
}

packages/image/platforms/android/java/com/nativescript/image/MatrixDrawable.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,16 @@ public MatrixDrawable(@NonNull Drawable wrapped) {
5151
mWrapped.setCallback(this);
5252
}
5353

54+
/**
55+
* Expose the wrapped drawable for callers that need to inspect.
56+
*/
57+
public Drawable getWrappedDrawable() {
58+
return mWrapped;
59+
}
60+
5461
/**
5562
* Re-assert that the wrapped drawable's callback is this wrapper.
56-
* Call this if you suspect the wrapped drawable's callback may have been
57-
* changed.
63+
* Call this if you suspect the wrapped drawable's callback may have been changed.
5864
*/
5965
public void refreshWrappedCallback() {
6066
mWrapped.setCallback(this);
@@ -72,6 +78,13 @@ public void setMatrix(@Nullable Matrix matrix) {
7278
invalidateSelf();
7379
}
7480

81+
/**
82+
* Get a copy of the current transformation matrix.
83+
*/
84+
public Matrix getMatrix() {
85+
return new Matrix(mMatrix);
86+
}
87+
7588
@Override
7689
public void draw(@NonNull Canvas canvas) {
7790
int save = canvas.save();

packages/image/platforms/android/java/com/nativescript/image/MatrixDrawableImageViewTarget.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ public MatrixDrawableImageViewTarget(ImageView view, boolean waitForLayout) {
4242
* Default is true (clear). Call this immediately after creating the target if you want
4343
* to disable this with false.
4444
*/
45-
public void setClearFirst(boolean preserve) {
46-
this.mClearFirst = preserve;
45+
public void setClearFirst(boolean value) {
46+
this.mClearFirst = value;
4747
}
4848

4949
/**

packages/image/platforms/android/java/com/nativescript/image/MatrixImageView.java

Lines changed: 62 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import androidx.annotation.Nullable;
1414
import androidx.appcompat.widget.AppCompatImageView;
15+
import android.graphics.drawable.TransitionDrawable;
1516

1617
import android.graphics.Canvas;
1718
import android.graphics.Path;
@@ -32,27 +33,6 @@
3233
* ImageView that exposes setImageRotation(float) and coordinates rotation +
3334
* scaleType.
3435
*
35-
* Enhancements:
36-
* - Re-asserts MatrixDrawable -> wrapped drawable callback binding after the
37-
* wrapper is set on the ImageView,
38-
* to ensure the wrapped drawable's invalidation/schedule callbacks are received
39-
* by the wrapper.
40-
* - Starts/stops Animatable wrapped drawables consistently on
41-
* attach/detach/visibility changes.
42-
* - Aspect-ratio support (like MatrixImageView): setAspectRatio(float) to force
43-
* measured width/height to respect ratio.
44-
* - Supports rotation-aware measurement: when imageRotation is 90 or 270
45-
* degrees, the enforced aspect ratio
46-
* is inverted so measurement reflects the rotated content.
47-
* - Adds a noRatioEnforce flag that disables all aspect-ratio measurement logic
48-
* and simply calls super.onMeasure().
49-
* - Auto-size support: when width or height measure is not finite, the view can
50-
* size itself using
51-
* explicit imageWidth/imageHeight (setImageSize) or the drawable intrinsic
52-
* size.
53-
*
54-
* Protected fields allow subclasses (e.g. ZoomableMatrixImageView) to inspect
55-
* base matrix for clamping.
5636
*/
5737
public class MatrixImageView extends AppCompatImageView {
5838
// made protected so ZoomableMatrixImageView can compute clamped translations
@@ -402,6 +382,26 @@ public void cancelRotationAnimation() {
402382
}
403383
}
404384

385+
/**
386+
* Compute the transformation matrix for a given drawable based on current view size,
387+
* rotation, and scale type.
388+
*/
389+
public Matrix computeMatrixForDrawable(Drawable drawable) {
390+
Matrix matrix = new Matrix();
391+
if (drawable == null) {
392+
return matrix;
393+
}
394+
395+
int vw = getWidth() - getPaddingLeft() - getPaddingRight();
396+
int vh = getHeight() - getPaddingTop() - getPaddingBottom();
397+
398+
if (vw > 0 && vh > 0) {
399+
ScaleUtils.getImageMatrix(drawable, vw, vh, mImageRotation, mAppliedScaleType, matrix);
400+
}
401+
402+
return matrix;
403+
}
404+
405405
@Override
406406
public void setImageDrawable(@Nullable Drawable drawable) {
407407
if (drawable == null) {
@@ -412,45 +412,55 @@ public void setImageDrawable(@Nullable Drawable drawable) {
412412
}
413413
setImageSize(0, 0);
414414
super.setImageDrawable(null);
415-
// clear implicit image size when drawable removed
416-
// (do not clear explicit mImageWidth/mImageHeight set by caller)
417415
return;
418416
}
417+
418+
// Special handling for TransitionDrawable - use it directly without wrapping
419+
if (drawable instanceof TransitionDrawable) {
420+
setImageSize(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
421+
super.setImageDrawable(drawable);
422+
mLastComposedDrawable = null;
423+
// Don't call updateBaseMatrix here - let the transition finish
424+
return;
425+
}
426+
419427
setImageSize(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight());
420-
MatrixDrawable md = new MatrixDrawable(drawable);
421-
// set the wrapper as the ImageView drawable - AppCompatImageView will set the
422-
// wrapper's callback to the view.
423-
super.setImageDrawable(md);
424-
425-
// Re-assert the wrapped drawable's callback -> wrapper relationship (safe no-op
426-
// if already set).
427-
md.refreshWrappedCallback();
428-
429-
// If caller hasn't set an explicit image size, capture drawable intrinsic size
430-
// as a hint for measuring
431-
if (mImageWidth <= 0 || mImageHeight <= 0) {
432-
int iw = drawable.getIntrinsicWidth();
433-
int ih = drawable.getIntrinsicHeight();
434-
if (iw > 0 && ih > 0) {
435-
mImageWidth = iw;
436-
mImageHeight = ih;
437-
// we captured a size that may affect measurement
438-
requestLayout();
439-
}
428+
429+
// If already a MatrixDrawable, use it directly
430+
if (drawable instanceof MatrixDrawable) {
431+
super.setImageDrawable(drawable);
432+
((MatrixDrawable) drawable).refreshWrappedCallback();
433+
} else {
434+
// Wrap in MatrixDrawable
435+
MatrixDrawable md = new MatrixDrawable(drawable);
436+
super.setImageDrawable(md);
437+
md.refreshWrappedCallback();
440438
}
441439

442-
// Ensure the next updateBaseMatrix applies the matrix to the new wrapper instance.
443-
mLastComposedDrawable = null;
444-
updateBaseMatrix();
445-
446-
// If underlying drawable is animatable and ImageView is visible, start it
447-
if (drawable instanceof Animatable && getVisibility() == VISIBLE) {
448-
((Animatable) drawable).start();
440+
// If caller hasn't set an explicit image size, capture drawable intrinsic size
441+
// as a hint for measuring
442+
if (mImageWidth <= 0 || mImageHeight <= 0) {
443+
int iw = drawable.getIntrinsicWidth();
444+
int ih = drawable.getIntrinsicHeight();
445+
if (iw > 0 && ih > 0) {
446+
mImageWidth = iw;
447+
mImageHeight = ih;
448+
requestLayout();
449449
}
450450
}
451451

452-
// cache last composed matrix so we can no-op identical updates (avoid repeated invalidates)
453-
private final Matrix mLastComposedMatrix = new Matrix();
452+
mLastComposedDrawable = null;
453+
updateBaseMatrix();
454+
455+
// If underlying drawable is animatable and ImageView is visible, start it
456+
Drawable inner = drawable instanceof MatrixDrawable ? ((MatrixDrawable) drawable).getWrappedDrawable() : drawable;
457+
if (inner instanceof Animatable && getVisibility() == VISIBLE) {
458+
((Animatable) inner).start();
459+
}
460+
}
461+
462+
// cache last composed matrix so we can no-op identical updates (avoid repeated invalidates)
463+
private final Matrix mLastComposedMatrix = new Matrix();
454464
// The drawable instance we last applied mLastComposedMatrix to. If a new drawable is
455465
// installed (wrapper changed), we must still apply the composed matrix even if values match.
456466
private Drawable mLastComposedDrawable = null;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.nativescript.image;
2+
3+
import android.graphics.Matrix;
4+
import android.graphics.drawable.Drawable;
5+
import android.graphics.drawable.TransitionDrawable;
6+
import android.widget.ImageView;
7+
import androidx.annotation.NonNull;
8+
import com.bumptech.glide.request.transition.Transition;
9+
10+
public class MatrixPreservingCrossFadeTransition implements Transition<Drawable> {
11+
private final int duration;
12+
13+
public MatrixPreservingCrossFadeTransition(int duration) {
14+
this.duration = duration;
15+
}
16+
17+
@Override
18+
public boolean transition(@NonNull Drawable current, @NonNull ViewAdapter adapter) {
19+
Drawable previous = adapter.getCurrentDrawable();
20+
if (previous == null) {
21+
return false;
22+
}
23+
24+
// Get the ImageView from the adapter
25+
ImageView view = null;
26+
if (adapter instanceof com.bumptech.glide.request.target.ViewTarget) {
27+
view = (ImageView) ((com.bumptech.glide.request.target.ViewTarget<?, ?>) adapter).getView();
28+
}
29+
30+
// Wrap current in MatrixDrawable with computed matrix if we have a MatrixImageView
31+
Drawable wrappedCurrent;
32+
if (view instanceof MatrixImageView) {
33+
MatrixImageView miv = (MatrixImageView) view;
34+
if (current instanceof MatrixDrawable) {
35+
wrappedCurrent = ((MatrixDrawable) current);
36+
} else {
37+
wrappedCurrent = new MatrixDrawable(current);
38+
}
39+
Matrix matrix = miv.computeMatrixForDrawable((MatrixDrawable)wrappedCurrent);
40+
((MatrixDrawable)wrappedCurrent).setMatrix(matrix);
41+
} else {
42+
wrappedCurrent = current;
43+
}
44+
45+
// Create TransitionDrawable
46+
final TransitionDrawable td = new TransitionDrawable(new Drawable[] { previous, wrappedCurrent });
47+
td.setCrossFadeEnabled(true);
48+
49+
// Set the TransitionDrawable - MatrixImageView will handle it specially
50+
adapter.setDrawable(td);
51+
52+
// After transition completes, set the final MatrixDrawable
53+
// final MatrixDrawable finalDrawable = (MatrixDrawable)wrappedCurrent;
54+
// if (view instanceof MatrixImageView) {
55+
// view.postDelayed(new Runnable() {
56+
// @Override
57+
// public void run() {
58+
// adapter.setDrawable(finalDrawable);
59+
// }
60+
// }, duration);
61+
// }
62+
63+
td.startTransition(duration);
64+
return true;
65+
}
66+
}

0 commit comments

Comments
 (0)