diff --git a/scratchoff/src/main/java/com/jackpocket/scratchoff/ScratchableLayoutDrawer.java b/scratchoff/src/main/java/com/jackpocket/scratchoff/ScratchableLayoutDrawer.java index a870a0f..7803c15 100644 --- a/scratchoff/src/main/java/com/jackpocket/scratchoff/ScratchableLayoutDrawer.java +++ b/scratchoff/src/main/java/com/jackpocket/scratchoff/ScratchableLayoutDrawer.java @@ -60,6 +60,8 @@ public interface Delegate { private Long activeClearTag = 0L; + private boolean activePathRecoveryEnabled = false; + private WeakReference initializeLayoutTarget = new WeakReference<>(null); private ViewTreeObserver.OnGlobalLayoutListener initializationGlobalLayoutListener; @@ -443,4 +445,13 @@ public ScratchableLayoutDrawer setClearAnimationInterpolator(Interpolator clearA return this; } + + @SuppressWarnings("WeakerAccess") + public ScratchableLayoutDrawer setActivePathRecoveryEnabled(boolean activePathRecoveryEnabled) { + this.activePathRecoveryEnabled = activePathRecoveryEnabled; + + pathManager.setActivePathRecoveryEnabled(activePathRecoveryEnabled); + + return this; + } } diff --git a/scratchoff/src/main/java/com/jackpocket/scratchoff/ScratchoffController.java b/scratchoff/src/main/java/com/jackpocket/scratchoff/ScratchoffController.java index 975195a..c5a5d66 100644 --- a/scratchoff/src/main/java/com/jackpocket/scratchoff/ScratchoffController.java +++ b/scratchoff/src/main/java/com/jackpocket/scratchoff/ScratchoffController.java @@ -79,6 +79,8 @@ public interface ThresholdChangedListener { private final LinkedBlockingQueue history = new LinkedBlockingQueue(); private boolean stateRestorationEnabled; + private boolean activePathRecoveryEnabled = false; + /** * Create a new {@link ScratchoffController} instance targeting a scratchable layout. */ @@ -152,7 +154,8 @@ public ScratchoffController attach() { protected ScratchableLayoutDrawer createLayoutDrawer() { return new ScratchableLayoutDrawer(this) .setClearAnimationDurationMs(clearAnimationDurationMs) - .setClearAnimationInterpolator(clearAnimationInterpolator); + .setClearAnimationInterpolator(clearAnimationInterpolator) + .setActivePathRecoveryEnabled(activePathRecoveryEnabled); } protected ScratchoffThresholdProcessor createThresholdProcessor() { @@ -480,6 +483,22 @@ public ScratchoffController setStateRestorationEnabled(boolean stateRestorationE return this; } + /** + * Set whether or not to recover from a null active {@link android.graphics.Path} during + * a touch-move event, instead of allowing a {@link NullPointerException} to be thrown. + * This can occur when a move event arrives without a preceding down event, + * e.g. due to race conditions during layout callbacks. + * The default for this value is false for the original (crashing) behavior. + */ + public ScratchoffController setActivePathRecoveryEnabled( + boolean activePathRecoveryEnabled + ) { + + this.activePathRecoveryEnabled = activePathRecoveryEnabled; + + return this; + } + public View getViewBehind() { return behindView.get(); } diff --git a/scratchoff/src/main/java/com/jackpocket/scratchoff/paths/ScratchPathManager.java b/scratchoff/src/main/java/com/jackpocket/scratchoff/paths/ScratchPathManager.java index b04897b..8b707e2 100644 --- a/scratchoff/src/main/java/com/jackpocket/scratchoff/paths/ScratchPathManager.java +++ b/scratchoff/src/main/java/com/jackpocket/scratchoff/paths/ScratchPathManager.java @@ -18,6 +18,8 @@ public class ScratchPathManager implements ScratchPathPointsAggregator { private final ArrayList paths = new ArrayList<>(); private float scale = 1f; + private boolean activePathRecoveryEnabled = false; + public ScratchPathManager() { } @@ -27,6 +29,12 @@ public ScratchPathManager setScale(float scale) { return this; } + public ScratchPathManager setActivePathRecoveryEnabled(boolean activePathRecoveryEnabled) { + this.activePathRecoveryEnabled = activePathRecoveryEnabled; + + return this; + } + @Override public void addScratchPathPoints(Collection events) { for (ScratchPathPoint event : events) { @@ -82,6 +90,17 @@ protected void handleTouchMove(int pointerIndex, float x, float y) { Path activePath = this.activePaths[pointerIndex]; + // Recover from a null activePath caused by a move event arriving without + // a preceding down event (e.g. due to race conditions during layout callbacks) + if (activePath == null) { + if (!activePathRecoveryEnabled) { + return; + } + + createPath(pointerIndex, x, y); + activePath = this.activePaths[pointerIndex]; + } + // If the active Path has been drawn, it would have been reset to an empty state if (activePath.isEmpty()) { activePath.moveTo(x, y); diff --git a/scratchoff/src/test/java/com/jackpocket/scratchoff/paths/ScratchPathManagerTests.kt b/scratchoff/src/test/java/com/jackpocket/scratchoff/paths/ScratchPathManagerTests.kt index 659aea9..f2ccc5e 100644 --- a/scratchoff/src/test/java/com/jackpocket/scratchoff/paths/ScratchPathManagerTests.kt +++ b/scratchoff/src/test/java/com/jackpocket/scratchoff/paths/ScratchPathManagerTests.kt @@ -61,6 +61,29 @@ class ScratchPathManagerTests { assertEquals(value, pathBounds.isEmpty) } + @Test + fun testMoveWithNullActivePathIsIgnoredWhenRecoveryDisabled() { + val manager = ScratchPathManager() + + assertEquals(0, manager.paths.size) + + manager.handleTouchMove(0, 1f, 1f) + + assertEquals(0, manager.paths.size) + } + + @Test + fun testMoveWithNullActivePathCreatesPathWhenRecoveryEnabled() { + val manager = ScratchPathManager() + manager.setActivePathRecoveryEnabled(true) + + assertEquals(0, manager.paths.size) + + manager.handleTouchMove(0, 1f, 1f) + + assertEquals(1, manager.paths.size) + } + @Test fun testTouchDownAndMovePassthroughIncludesScalar() { val manager = object: ScratchPathManager() {