/*
* Copyright 2017 Gabor Varadi
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.zhuinden.simplestack;
import android.content.Context;
import android.support.annotation.IntDef;
import android.support.annotation.NonNull;
import java.lang.annotation.Retention;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import static java.lang.annotation.RetentionPolicy.SOURCE;
/**
* The {@link Backstack} holds the current state, in the form of a list of Objects.
* It queues up {@link StateChange}s while a {@link StateChanger} is not available.
* When a {@link StateChanger} is available, it attempts to execute the queued {@link StateChange}s.
* A {@link StateChanger} can be either set to {@link Backstack#INITIALIZE}, or to {@link Backstack#REATTACH}.
* {@link Backstack#INITIALIZE} begins an initializing {@link StateChange} to set up initial state, {@link Backstack#REATTACH} does not.
*/
public class Backstack {
public static <T> T getKey(Context context) {
return KeyContextWrapper.getKey(context);
}
//
@Retention(SOURCE)
@IntDef({INITIALIZE, REATTACH})
private @interface StateChangerRegisterMode {
}
public static final int INITIALIZE = 0;
public static final int REATTACH = 1;
//
private final List<Object> originalStack = new ArrayList<>();
private final List<Object> initialKeys;
private List<Object> initialParameters;
private List<Object> stack = originalStack;
private LinkedList<PendingStateChange> queuedStateChanges = new LinkedList<>();
private StateChanger stateChanger;
/**
* Creates the Backstack with the provided initial keys.
*
* @param initialKeys
*/
public Backstack(@NonNull Object... initialKeys) {
if(initialKeys == null || initialKeys.length <= 0) {
throw new IllegalArgumentException("At least one initial key must be defined");
}
this.initialKeys = Collections.unmodifiableList(new ArrayList<>(Arrays.asList(initialKeys)));
setInitialParameters(new ArrayList<>(this.initialKeys));
}
/**
* Creates the Backstack with the provided initial keys.
*
* @param initialKeys
*/
public Backstack(@NonNull List<?> initialKeys) {
if(initialKeys == null) {
throw new NullPointerException("Initial key list should not be null");
}
if(initialKeys.size() <= 0) {
throw new IllegalArgumentException("Initial key list should contain at least one element");
}
this.initialKeys = Collections.unmodifiableList(new ArrayList<>(initialKeys));
setInitialParameters(new ArrayList<>(this.initialKeys));
}
void setInitialParameters(List<?> initialKeys) {
if(initialKeys == null || initialKeys.size() <= 0) {
throw new IllegalArgumentException("At least one initial key must be defined");
}
this.initialParameters = new ArrayList<>(initialKeys);
}
/**
* Indicates whether a {@link StateChanger} is set.
*
* @return true if a {@link StateChanger} is set, false otherwise.
*/
public boolean hasStateChanger() {
return stateChanger != null;
}
/**
* Sets a {@link StateChanger}.
*
* @param stateChanger the new {@link StateChanger}, which cannot be null.
* @param registerMode indicates whether the {@link StateChanger} is to be initialized, or is just reattached.
*/
public void setStateChanger(@NonNull StateChanger stateChanger, @StateChangerRegisterMode int registerMode) {
if(stateChanger == null) {
throw new NullPointerException("New state changer cannot be null");
}
this.stateChanger = stateChanger;
if(registerMode == INITIALIZE && (queuedStateChanges.size() <= 1 || stack.isEmpty())) {
if(!beginStateChangeIfPossible()) {
ArrayList<Object> newHistory = new ArrayList<>();
newHistory.addAll(selectActiveHistory());
stack = initialParameters;
enqueueStateChange(newHistory, StateChange.REPLACE, true);
}
return;
}
beginStateChangeIfPossible();
}
/**
* Removes the {@link StateChanger}.
*/
public void removeStateChanger() {
this.stateChanger = null;
}
/**
* Goes to the new key.
* If the key is found, then it goes backward to the existing key.
* If the key is not found, then it goes forward to the newly added key.
*
* @param newKey the target state.
*/
public void goTo(@NonNull Object newKey) {
checkNewKey(newKey);
ArrayList<Object> newHistory = new ArrayList<>();
boolean isNewKey = true;
for(Object key : selectActiveHistory()) {
newHistory.add(key);
if(key.equals(newKey)) {
isNewKey = false;
break;
}
}
int direction;
if(isNewKey) {
newHistory.add(newKey);
direction = StateChange.FORWARD;
} else {
direction = StateChange.BACKWARD;
}
enqueueStateChange(newHistory, direction, false);
}
/**
* Goes back in the history.
* If the key is found, then it goes backward to the existing key.
* If the key is not found, then it goes forward to the newly added key.
*
* @return true if a state change is pending or is handled with a state change, false if there is only one state left.
*/
public boolean goBack() {
if(isStateChangePending()) {
return true;
}
if(stack.size() <= 1) {
resetBackstack();
return false;
}
ArrayList<Object> newHistory = new ArrayList<>();
List<Object> activeHistory = selectActiveHistory();
for(int i = 0; i < activeHistory.size() - 1; i++) {
newHistory.add(activeHistory.get(i));
}
enqueueStateChange(newHistory, StateChange.BACKWARD, false);
return true;
}
private void resetBackstack() {
stack.clear();
initialParameters = new ArrayList<>(initialKeys);
}
/**
* Sets the provided state list as the new active history.
*
* @param newHistory the new active history.
* @param direction The direction of the state change: BACKWARD, FORWARD or REPLACE.
*/
public void setHistory(@NonNull List<Object> newHistory, @StateChange.StateChangeDirection int direction) {
checkNewHistory(newHistory);
enqueueStateChange(newHistory, direction, false);
}
/**
* Returns the last element in the list, or null if the history is empty.
*
* @param <T> the type of the key
* @return the top key
*/
public <T> T top() {
if(stack.isEmpty()) {
return null;
}
// noinspection unchecked
return (T) stack.get(stack.size() - 1);
}
/**
* Returns an unmodifiable copy of the current history.
*
* @return the unmodifiable copy of history.
*/
public List<Object> getHistory() {
List<Object> copy = new ArrayList<>();
copy.addAll(stack);
return Collections.unmodifiableList(copy);
}
/**
* Returns an unmodifiable list that contains the keys this backstack is initialized with.
*
* @return the list of keys used at first initialization
*/
public List<Object> getInitialParameters() {
return initialParameters;
}
/**
* Returns whether there is at least one queued {@link StateChange}.
*
* @return true if there is at least one enqueued {@link StateChange}.
*/
public boolean isStateChangePending() {
return !queuedStateChanges.isEmpty();
}
private void enqueueStateChange(List<Object> newHistory, int direction, boolean initialization) {
PendingStateChange pendingStateChange = new PendingStateChange(newHistory, direction, initialization);
queuedStateChanges.add(pendingStateChange);
beginStateChangeIfPossible();
}
private List<Object> selectActiveHistory() {
if(stack.isEmpty() && queuedStateChanges.size() <= 0) {
return initialParameters;
} else if(queuedStateChanges.size() <= 0) {
return stack;
} else {
return queuedStateChanges.getLast().newHistory;
}
}
private boolean beginStateChangeIfPossible() {
if(hasStateChanger() && isStateChangePending()) {
PendingStateChange pendingStateChange = queuedStateChanges.getFirst();
if(pendingStateChange.getStatus() == PendingStateChange.Status.ENQUEUED) {
pendingStateChange.setStatus(PendingStateChange.Status.IN_PROGRESS);
changeState(pendingStateChange);
return true;
}
}
return false;
}
private void changeState(final PendingStateChange pendingStateChange) {
boolean initialization = pendingStateChange.initialization;
List<Object> newHistory = pendingStateChange.newHistory;
@StateChange.StateChangeDirection int direction = pendingStateChange.direction;
List<Object> previousState;
if(initialization) {
previousState = Collections.emptyList();
} else {
previousState = new ArrayList<>();
previousState.addAll(stack);
}
final StateChange stateChange = new StateChange(this, Collections.unmodifiableList(previousState),
Collections.unmodifiableList(newHistory),
direction);
StateChanger.Callback completionCallback = new StateChanger.Callback() {
@Override
public void stateChangeComplete() {
if(!pendingStateChange.didForceExecute) {
if(pendingStateChange.getStatus() == PendingStateChange.Status.COMPLETED) {
throw new IllegalStateException("State change completion cannot be called multiple times!");
}
completeStateChange(stateChange);
}
}
};
pendingStateChange.completionCallback = completionCallback;
stateChanger.handleStateChange(stateChange, completionCallback);
}
private void completeStateChange(StateChange stateChange) {
if(initialParameters == stack) {
stack = originalStack;
}
stack.clear();
stack.addAll(stateChange.newState);
PendingStateChange pendingStateChange = queuedStateChanges.removeFirst();
pendingStateChange.setStatus(PendingStateChange.Status.COMPLETED);
notifyCompletionListeners(stateChange);
beginStateChangeIfPossible();
}
// completion listeners
/**
* CompletionListener allows you to listen to when a StateChange has been completed.
* They are registered to the backstack with {@link Backstack#addCompletionListener(CompletionListener)}.
* They are unregistered from the backstack with {@link Backstack#removeCompletionListener(CompletionListener)} methods.
*/
public interface CompletionListener {
/**
* Callback method that is called when a {@link StateChange} is complete.
*
* @param stateChange the state change that has been completed.
*/
void stateChangeCompleted(@NonNull StateChange stateChange);
}
private LinkedList<CompletionListener> completionListeners = new LinkedList<>();
/**
* Registers the {@link Backstack.CompletionListener}.
*
* @param completionListener The non-null completion listener to be registered.
*/
public void addCompletionListener(@NonNull CompletionListener completionListener) {
if(completionListener == null) {
throw new IllegalArgumentException("Null completion listener cannot be added!");
}
completionListeners.add(completionListener);
}
/**
* Unregisters the {@link Backstack.CompletionListener}.
*
* @param completionListener The non-null completion listener to be unregistered.
*/
public void removeCompletionListener(@NonNull CompletionListener completionListener) {
if(completionListener == null) {
throw new IllegalArgumentException("Null completion listener cannot be removed!");
}
completionListeners.remove(completionListener);
}
private void notifyCompletionListeners(StateChange stateChange) {
for(CompletionListener completionListener : completionListeners) {
completionListener.stateChangeCompleted(stateChange);
}
}
// force execute
/**
* If there is a state change in progress, then calling this method will force it to be completed immediately.
* Any future calls to {@link StateChanger.Callback#stateChangeComplete()} for that given state change are ignored.
*/
public void executePendingStateChange() {
if(isStateChangePending()) {
PendingStateChange pendingStateChange = queuedStateChanges.getFirst();
if(pendingStateChange.getStatus() == PendingStateChange.Status.IN_PROGRESS) {
pendingStateChange.completionCallback.stateChangeComplete();
pendingStateChange.didForceExecute = true;
}
}
}
// argument checks
private void checkNewHistory(List<Object> newHistory) {
if(newHistory == null || newHistory.isEmpty()) {
throw new IllegalArgumentException("New history cannot be null or empty");
}
}
private void checkNewKey(Object newKey) {
if(newKey == null) {
throw new IllegalArgumentException("Key cannot be null");
}
}
}