splitChar;
+
+ CharacterTokenizer(char[] splitChar){
+ super();
+ this.splitChar = new ArrayList<>(splitChar.length);
+ for(char c : splitChar) this.splitChar.add(c);
+ }
+
+ public int findTokenStart(CharSequence text, int cursor) {
+ int i = cursor;
+
+ while (i > 0 && !splitChar.contains(text.charAt(i - 1))) {
+ i--;
+ }
+ while (i < cursor && text.charAt(i) == ' ') {
+ i++;
+ }
+
+ return i;
+ }
+
+ public int findTokenEnd(CharSequence text, int cursor) {
+ int i = cursor;
+ int len = text.length();
+
+ while (i < len) {
+ if (splitChar.contains(text.charAt(i))) {
+ return i;
+ } else {
+ i++;
+ }
+ }
+
+ return len;
+ }
+
+ public CharSequence terminateToken(CharSequence text) {
+ int i = text.length();
+
+ while (i > 0 && text.charAt(i - 1) == ' ') {
+ i--;
+ }
+
+ if (i > 0 && splitChar.contains(text.charAt(i - 1))) {
+ return text;
+ } else {
+ // Try not to use a space as a token character
+ String token = (splitChar.size()>1 && splitChar.get(0)==' ' ? splitChar.get(1) : splitChar.get(0))+" ";
+ if (text instanceof Spanned) {
+ SpannableString sp = new SpannableString(text + token);
+ TextUtils.copySpansFrom((Spanned) text, 0, text.length(),
+ Object.class, sp, 0);
+ return sp;
+ } else {
+ return text + token;
+ }
+ }
+ }
+}
diff --git a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/CountSpan.java b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/CountSpan.java
new file mode 100644
index 000000000..5d46eff94
--- /dev/null
+++ b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/CountSpan.java
@@ -0,0 +1,29 @@
+package com.microsoft.fluentui.tokenautocomplete;
+
+import android.content.Context;
+import android.util.TypedValue;
+import android.widget.TextView;
+
+/**
+ * Span that displays +[x]
+ *
+ * Created on 2/3/15.
+ * @author mgod
+ */
+
+public class CountSpan extends ViewSpan {
+ public String text = "";
+
+ public CountSpan(int count, Context ctx, int textColor, int textSize, int maxWidth) {
+ super(new TextView(ctx), maxWidth);
+ TextView v = (TextView)view;
+ v.setTextColor(textColor);
+ v.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize);
+ setCount(count);
+ }
+
+ public void setCount(int c) {
+ text = "+" + c;
+ ((TextView)view).setText(text);
+ }
+}
diff --git a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/FilteredArrayAdapter.java b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/FilteredArrayAdapter.java
new file mode 100644
index 000000000..c1109f8bc
--- /dev/null
+++ b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/FilteredArrayAdapter.java
@@ -0,0 +1,164 @@
+package com.microsoft.fluentui.tokenautocomplete;
+
+import android.content.Context;
+import android.widget.ArrayAdapter;
+import android.widget.Filter;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Simplified custom filtered ArrayAdapter
+ * override keepObject with your test for filtering
+ *
+ * Based on gist
+ * FilteredArrayAdapter by Tobias Schürg
+ *
+ * Created on 9/17/13.
+ * @author mgod
+ */
+
+abstract public class FilteredArrayAdapter extends ArrayAdapter {
+
+ private List originalObjects;
+ private Filter filter;
+
+ /**
+ * Constructor
+ *
+ * @param context The current context.
+ * @param resource The resource ID for a layout file containing a TextView to use when
+ * instantiating views.
+ * @param objects The objects to represent in the ListView.
+ */
+ public FilteredArrayAdapter(Context context, int resource, T[] objects) {
+ this(context, resource, 0, objects);
+ }
+
+ /**
+ * Constructor
+ *
+ * @param context The current context.
+ * @param resource The resource ID for a layout file containing a layout to use when
+ * instantiating views.
+ * @param textViewResourceId The id of the TextView within the layout resource to be populated
+ * @param objects The objects to represent in the ListView.
+ */
+ public FilteredArrayAdapter(Context context, int resource, int textViewResourceId, T[] objects) {
+ this(context, resource, textViewResourceId, new ArrayList(Arrays.asList(objects)));
+ }
+
+ /**
+ * Constructor
+ *
+ * @param context The current context.
+ * @param resource The resource ID for a layout file containing a TextView to use when
+ * instantiating views.
+ * @param objects The objects to represent in the ListView.
+ */
+ @SuppressWarnings("unused")
+ public FilteredArrayAdapter(Context context, int resource, List objects) {
+ this(context, resource, 0, objects);
+ }
+
+ /**
+ * Constructor
+ *
+ * @param context The current context.
+ * @param resource The resource ID for a layout file containing a layout to use when
+ * instantiating views.
+ * @param textViewResourceId The id of the TextView within the layout resource to be populated
+ * @param objects The objects to represent in the ListView.
+ */
+ public FilteredArrayAdapter(Context context, int resource, int textViewResourceId, List objects) {
+ super(context, resource, textViewResourceId, new ArrayList(objects));
+ this.originalObjects = objects;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public void notifyDataSetChanged() {
+ ((AppFilter)getFilter()).setSourceObjects(this.originalObjects);
+ super.notifyDataSetChanged();
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public void notifyDataSetInvalidated(){
+ ((AppFilter)getFilter()).setSourceObjects(this.originalObjects);
+ super.notifyDataSetInvalidated();
+ }
+
+ @Override
+ public Filter getFilter() {
+ if (filter == null)
+ filter = new AppFilter(originalObjects);
+ return filter;
+ }
+
+ /**
+ * Filter method used by the adapter. Return true if the object should remain in the list
+ *
+ * @param obj object we are checking for inclusion in the adapter
+ * @param mask current text in the edit text we are completing against
+ * @return true if we should keep the item in the adapter
+ */
+ abstract protected boolean keepObject(T obj, String mask);
+
+ /**
+ * Class for filtering Adapter, relies on keepObject in FilteredArrayAdapter
+ *
+ * based on gist by Tobias Schürg
+ * in turn inspired by inspired by Alxandr
+ * (http://stackoverflow.com/a/2726348/570168)
+ */
+ private class AppFilter extends Filter {
+
+ private ArrayList sourceObjects;
+
+ public AppFilter(List objects) {
+ setSourceObjects(objects);
+ }
+
+ public void setSourceObjects(List objects) {
+ synchronized (this) {
+ sourceObjects = new ArrayList(objects);
+ }
+ }
+
+ @Override
+ protected FilterResults performFiltering(CharSequence chars) {
+ FilterResults result = new FilterResults();
+ if (chars != null && chars.length() > 0) {
+ String mask = chars.toString();
+ ArrayList keptObjects = new ArrayList();
+
+ for (T object : sourceObjects) {
+ if (keepObject(object, mask))
+ keptObjects.add(object);
+ }
+ result.count = keptObjects.size();
+ result.values = keptObjects;
+ } else {
+ // add all objects
+ result.values = sourceObjects;
+ result.count = sourceObjects.size();
+ }
+ return result;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ protected void publishResults(CharSequence constraint, FilterResults results) {
+ clear();
+ if (results.count > 0) {
+ FilteredArrayAdapter.this.addAll((Collection)results.values);
+ notifyDataSetChanged();
+ } else {
+ notifyDataSetInvalidated();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/HintSpan.java b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/HintSpan.java
new file mode 100644
index 000000000..b17834efb
--- /dev/null
+++ b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/HintSpan.java
@@ -0,0 +1,16 @@
+package com.microsoft.fluentui.tokenautocomplete;
+
+import android.content.res.ColorStateList;
+import android.text.style.TextAppearanceSpan;
+
+/**
+ * Subclass of TextAppearanceSpan just to work with how Spans get detected
+ *
+ * Created on 2/3/15.
+ * @author mgod
+ */
+public class HintSpan extends TextAppearanceSpan {
+ public HintSpan(String family, int style, int size, ColorStateList color, ColorStateList linkColor) {
+ super(family, style, size, color, linkColor);
+ }
+}
diff --git a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/TokenCompleteTextView.java b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/TokenCompleteTextView.java
new file mode 100644
index 000000000..238804d45
--- /dev/null
+++ b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/TokenCompleteTextView.java
@@ -0,0 +1,1563 @@
+package com.microsoft.fluentui.tokenautocomplete;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.graphics.Rect;
+import android.graphics.Typeface;
+import android.os.Build;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.Editable;
+import android.text.InputFilter;
+import android.text.InputType;
+import android.text.Layout;
+import android.text.NoCopySpan;
+import android.text.Selection;
+import android.text.SpanWatcher;
+import android.text.Spannable;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.text.method.QwertyKeyListener;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.ExtractedText;
+import android.view.inputmethod.ExtractedTextRequest;
+import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputConnectionWrapper;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.Filter;
+import android.widget.ListView;
+import android.widget.MultiAutoCompleteTextView;
+import android.widget.TextView;
+import androidx.annotation.NonNull;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Gmail style auto complete view with easy token customization
+ * override getViewForObject to provide your token view
+ *
+ * Created by mgod on 9/12/13.
+ *
+ * @author mgod
+ */
+public abstract class TokenCompleteTextView extends androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView implements TextView.OnEditorActionListener {
+ //Logging
+ public static final String TAG = "TokenAutoComplete";
+
+ //When the token is deleted...
+ public enum TokenDeleteStyle {
+ _Parent, //...do the parent behavior, not recommended
+ Clear, //...clear the underlying text
+ PartialCompletion, //...return the original text used for completion
+ ToString //...replace the token with toString of the token object
+ }
+
+ //When the user clicks on a token...
+ public enum TokenClickStyle {
+ None(false), //...do nothing, but make sure the cursor is not in the token
+ Delete(false),//...delete the token
+ Select(true),//...select the token. A second click will delete it.
+ SelectDeselect(true);
+
+ private boolean mIsSelectable = false;
+
+ TokenClickStyle(final boolean selectable) {
+ mIsSelectable = selectable;
+ }
+
+ public boolean isSelectable() {
+ return mIsSelectable;
+ }
+ }
+
+ private char[] splitChar = {',', ';'};
+ private Tokenizer tokenizer;
+ private T selectedObject;
+ private TokenListener listener;
+ private TokenSpanWatcher spanWatcher;
+ private TokenTextWatcher textWatcher;
+ private ArrayList objects;
+ private List.TokenImageSpan> hiddenSpans;
+ private TokenDeleteStyle deletionStyle = TokenDeleteStyle._Parent;
+ private TokenClickStyle tokenClickStyle = TokenClickStyle.None;
+ private CharSequence prefix = "";
+ private boolean hintVisible = false;
+ private Layout lastLayout = null;
+ private boolean allowDuplicates = true;
+ private boolean focusChanging = false;
+ private boolean initialized = false;
+ private boolean performBestGuess = true;
+ private boolean savingState = false;
+ private boolean shouldFocusNext = false;
+ private boolean allowCollapse = true;
+
+ private int tokenLimit = -1;
+
+ /**
+ * Add the TextChangedListeners
+ */
+ protected void addListeners() {
+ Editable text = getText();
+ if (text != null) {
+ text.setSpan(spanWatcher, 0, text.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+ addTextChangedListener(textWatcher);
+ }
+ }
+
+ /**
+ * Remove the TextChangedListeners
+ */
+ protected void removeListeners() {
+ Editable text = getText();
+ if (text != null) {
+ TokenSpanWatcher[] spanWatchers = text.getSpans(0, text.length(), TokenSpanWatcher.class);
+ for (TokenSpanWatcher watcher : spanWatchers) {
+ text.removeSpan(watcher);
+ }
+ removeTextChangedListener(textWatcher);
+ }
+ }
+
+ /**
+ * Initialise the variables and various listeners
+ */
+ private void init() {
+ if (initialized) return;
+
+ // Initialise variables
+ setTokenizer(new MultiAutoCompleteTextView.CommaTokenizer());
+ objects = new ArrayList<>();
+ Editable text = getText();
+ assert null != text;
+ spanWatcher = new TokenSpanWatcher();
+ textWatcher = new TokenTextWatcher();
+ hiddenSpans = new ArrayList<>();
+
+ // Initialise TextChangedListeners
+ addListeners();
+
+ setTextIsSelectable(false);
+ setLongClickable(false);
+
+ //In theory, get the soft keyboard to not supply suggestions. very unreliable < API 11
+ setInputType(getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS | InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE);
+ setHorizontallyScrolling(false);
+
+ // Listen to IME action keys
+ setOnEditorActionListener(this);
+
+ // Initialise the textfilter (listens for the splitchars)
+ setFilters(new InputFilter[]{new InputFilter() {
+ @Override
+ public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
+ // Token limit check
+ if (tokenLimit != -1 && objects.size() == tokenLimit) {
+ return "";
+ } else if (source.length() == 1) {//Detect split characters, remove them and complete the current token instead
+ if (isSplitChar(source.charAt(0))) {
+ performCompletion();
+ return "";
+ }
+ }
+
+ //We need to not do anything when we would delete the prefix
+ if (dstart < prefix.length()) {
+ //when settext is called, which should only be called during
+ //restoring, dstart and dend are 0. If not checked, it will clear out the prefix.
+ //this is why we need to return null in this if condition to preserve state.
+ if (dstart == 0 && dend == 0) {
+ return null;
+ } else if (dend <= prefix.length()) {
+ //Don't do anything
+ return prefix.subSequence(dstart, dend);
+ } else {
+ //Delete everything up to the prefix
+ return prefix.subSequence(dstart, prefix.length());
+ }
+ }
+ return null;
+ }
+ }});
+
+ //We had _Parent style during initialization to handle an edge case in the parent
+ //now we can switch to Clear, usually the best choice
+ setDeletionStyle(TokenDeleteStyle.Clear);
+ initialized = true;
+ }
+
+ public TokenCompleteTextView(Context context) {
+ super(context);
+ init();
+ }
+
+ public TokenCompleteTextView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public TokenCompleteTextView(Context context, AttributeSet attrs, int defStyle) {
+ super(context, attrs, defStyle);
+ init();
+ }
+
+ @Override
+ protected void performFiltering(@NonNull CharSequence text, int start, int end,
+ int keyCode) {
+ if (start < prefix.length()) {
+ start = prefix.length();
+ }
+ Filter filter = getFilter();
+ if (filter != null) {
+ if (hintVisible) {
+ filter.filter("");
+ } else {
+ filter.filter(text.subSequence(start, end), this);
+ }
+ }
+ }
+
+
+ @Override
+ public void setTokenizer(Tokenizer t) {
+ super.setTokenizer(t);
+ tokenizer = t;
+ }
+
+ /**
+ * Set the action to be taken when a Token is removed
+ *
+ * @param dStyle The TokenDeleteStyle
+ */
+ public void setDeletionStyle(TokenDeleteStyle dStyle) {
+ deletionStyle = dStyle;
+ }
+
+ /**
+ * Set the action to be taken when a Token is clicked
+ *
+ * @param cStyle The TokenClickStyle
+ */
+ @SuppressWarnings("unused")
+ public void setTokenClickStyle(TokenClickStyle cStyle) {
+ tokenClickStyle = cStyle;
+ }
+
+ /**
+ * Set the listener that will be notified of changes in the Tokenlist
+ *
+ * @param l The TokenListener
+ */
+ public void setTokenListener(TokenListener l) {
+ listener = l;
+ }
+
+ /**
+ * Override if you want to prevent a token from being removed. Defaults to true.
+ * @param token the token to check
+ * @return false if the token should not be removed, true if it's ok to remove it.
+ */
+ @SuppressWarnings("unused")
+ public boolean isTokenRemovable(T token) {
+ return true;
+ }
+
+ /**
+ * A String of text that is shown before all the tokens inside the EditText
+ * (Think "To: " in an email address field. I would advise against this: use a label and a hint.
+ *
+ * @param p String with the hint
+ */
+ public void setPrefix(CharSequence p) {
+ //Have to clear and set the actual text before saving the prefix to avoid the prefix filter
+ prefix = "";
+ Editable text = getText();
+ if (text != null) {
+ text.insert(0, p);
+ }
+ prefix = p;
+
+ updateHint();
+ }
+
+ /**
+ * Get the list of Tokens
+ *
+ * @return List of tokens
+ */
+ public List getObjects() {
+ return objects;
+ }
+
+ /**
+ * Set a list of characters that should trigger the token creation
+ * Because spaces are difficult to handle, we add '§' as an additional splitChar
+ *
+ * @param splitChar char[] with a characters that trigger the token creation
+ */
+ public void setSplitChar(char[] splitChar) {
+ char[] fixed = splitChar;
+ if (splitChar[0] == ' ') {
+ fixed = new char[splitChar.length + 1];
+ fixed[0] = '§';
+ System.arraycopy(splitChar, 0, fixed, 1, splitChar.length);
+ }
+ this.splitChar = fixed;
+ // Keep the tokenizer and splitchars in sync
+ this.setTokenizer(new CharacterTokenizer(splitChar));
+ }
+
+ /**
+ * Sets a single character to trigger the token creation
+ *
+ * @param splitChar char that triggers the token creation
+ */
+ @SuppressWarnings("unused")
+ public void setSplitChar(char splitChar) {
+ setSplitChar(new char[]{splitChar});
+ }
+
+ /**
+ * Returns true if the character is currently configured as a splitChar
+ *
+ * @param c the char to test
+ * @return boolean
+ */
+ private boolean isSplitChar(char c) {
+ for (char split : splitChar) {
+ if (c == split) return true;
+ }
+ return false;
+ }
+
+ /**
+ * Sets whether to allow duplicate objects. If false, when the user selects
+ * an object that's already in the view, the current text is just cleared.
+ *
+ * Defaults to true. Requires that the objects implement equals() correctly.
+ *
+ * @param allow boolean
+ */
+ @SuppressWarnings("unused")
+ public void allowDuplicates(boolean allow) {
+ allowDuplicates = allow;
+ }
+
+ /**
+ * Set whether we try to guess an entry from the autocomplete spinner or allow any text to be
+ * entered
+ *
+ * @param guess true to enable guessing
+ */
+ @SuppressWarnings("unused")
+ public void performBestGuess(boolean guess) {
+ performBestGuess = guess;
+ }
+
+ /**
+ * Set whether the view should collapse to a single line when it loses focus.
+ *
+ * @param allowCollapse true if it should collapse
+ */
+ @SuppressWarnings("unused")
+ public void allowCollapse(boolean allowCollapse) {
+ this.allowCollapse = allowCollapse;
+ }
+
+ /**
+ * Set a number of tokens limit.
+ *
+ * @param tokenLimit The number of tokens permitted. -1 value disables limit.
+ */
+ @SuppressWarnings("unused")
+ public void setTokenLimit(int tokenLimit) {
+ this.tokenLimit = tokenLimit;
+ }
+
+ /**
+ * A token view for the object
+ *
+ * @param object the object selected by the user from the list
+ * @return a view to display a token in the text field for the object
+ */
+ abstract protected View getViewForObject(T object);
+
+ /**
+ * Provides a default completion when the user hits , and there is no item in the completion
+ * list
+ *
+ * @param completionText the current text we are completing against
+ * @return a best guess for what the user meant to complete
+ */
+ abstract protected T defaultObject(String completionText);
+
+ /**
+ * Correctly build accessibility string for token contents
+ *
+ * This seems to be a hidden API, but there doesn't seem to be another reasonable way
+ * @return custom string for accessibility
+ */
+ @SuppressWarnings("unused")
+ public CharSequence getTextForAccessibility() {
+ if (getObjects().size() == 0) {
+ return getText();
+ }
+
+ SpannableStringBuilder description = new SpannableStringBuilder();
+ Editable text = getText();
+ int selectionStart = -1;
+ int selectionEnd = -1;
+ int i;
+ //Need to take the existing tet buffer and
+ // - replace all tokens with a decent string representation of the object
+ // - set the selection span to the corresponding location in the new CharSequence
+ for (i = 0; i < text.length(); ++i) {
+ //See if this is where we should start the selection
+ int origSelectionStart = Selection.getSelectionStart(text);
+ if (i == origSelectionStart) {
+ selectionStart = description.length();
+ }
+ int origSelectionEnd = Selection.getSelectionEnd(text);
+ if (i == origSelectionEnd) {
+ selectionEnd = description.length();
+ }
+
+ //Replace token spans
+ TokenImageSpan[] tokens = text.getSpans(i, i, TokenImageSpan.class);
+ if (tokens.length > 0) {
+ TokenImageSpan token = tokens[0];
+ description = description.append(tokenizer.terminateToken(token.getToken().toString()));
+ i = text.getSpanEnd(token);
+ continue;
+ }
+
+ description = description.append(text.subSequence(i, i + 1));
+ }
+
+ int origSelectionStart = Selection.getSelectionStart(text);
+ if (i == origSelectionStart) {
+ selectionStart = description.length();
+ }
+ int origSelectionEnd = Selection.getSelectionEnd(text);
+ if (i == origSelectionEnd) {
+ selectionEnd = description.length();
+ }
+
+ if (selectionStart >= 0 && selectionEnd >= 0) {
+ Selection.setSelection(description, selectionStart, selectionEnd);
+ }
+
+ return description;
+ }
+
+ @Override
+ public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
+ super.onInitializeAccessibilityEvent(event);
+
+ if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED) {
+ CharSequence text = getTextForAccessibility();
+ event.setFromIndex(Selection.getSelectionStart(text));
+ event.setToIndex(Selection.getSelectionEnd(text));
+ event.setItemCount(text.length());
+ }
+ }
+
+ private int getCorrectedTokenEnd() {
+ Editable editable = getText();
+ int cursorPosition = getSelectionEnd();
+ return tokenizer.findTokenEnd(editable, cursorPosition);
+ }
+
+ private int getCorrectedTokenBeginning(int end) {
+ int start = tokenizer.findTokenStart(getText(), end);
+ if (start < prefix.length()) {
+ start = prefix.length();
+ }
+ return start;
+ }
+
+ protected String currentCompletionText() {
+ if (hintVisible) return ""; //Can't have any text if the hint is visible
+
+ Editable editable = getText();
+ int end = getCorrectedTokenEnd();
+ int start = getCorrectedTokenBeginning(end);
+
+ //Some keyboards add extra spaces when doing corrections, so
+ return TextUtils.substring(editable, start, end);
+ }
+
+ protected float maxTextWidth() {
+ return getWidth() - getPaddingLeft() - getPaddingRight();
+ }
+
+ boolean inInvalidate = false;
+
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
+ private void api16Invalidate() {
+ if (initialized && !inInvalidate) {
+ inInvalidate = true;
+ setShadowLayer(getShadowRadius(), getShadowDx(), getShadowDy(), getShadowColor());
+ inInvalidate = false;
+ }
+ }
+
+ @Override
+ public void invalidate() {
+ //Need to force the TextView private mEditor variable to reset as well on API 16 and up
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
+ api16Invalidate();
+ }
+
+ super.invalidate();
+ }
+
+ @Override
+ public boolean enoughToFilter() {
+ if (tokenizer == null || hintVisible) {
+ return false;
+ }
+
+ int cursorPosition = getSelectionEnd();
+
+ if (cursorPosition < 0) {
+ return false;
+ }
+
+ int end = getCorrectedTokenEnd();
+ int start = getCorrectedTokenBeginning(end);
+
+ //Don't allow 0 length entries to filter
+ return end - start >= Math.max(getThreshold(), 1);
+ }
+
+ @Override
+ public void performCompletion() {
+ if ((getAdapter() == null || getListSelection() == ListView.INVALID_POSITION) && enoughToFilter()) {
+ Object bestGuess;
+ if (getAdapter() != null && getAdapter().getCount() > 0 && performBestGuess) {
+ bestGuess = getAdapter().getItem(0);
+ } else {
+ bestGuess = defaultObject(currentCompletionText());
+ }
+ replaceText(convertSelectionToString(bestGuess));
+ } else {
+ super.performCompletion();
+ }
+ }
+
+ @Override
+ public InputConnection onCreateInputConnection(@NonNull EditorInfo outAttrs) {
+ InputConnection superConn = super.onCreateInputConnection(outAttrs);
+ if (superConn != null) {
+ TokenInputConnection conn = new TokenInputConnection(superConn, true);
+ outAttrs.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
+ outAttrs.imeOptions |= EditorInfo.IME_FLAG_NO_EXTRACT_UI;
+ return conn;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Create a token and hide the keyboard when the user sends the DONE IME action
+ * Use IME_NEXT if you want to create a token and go to the next field
+ */
+ private void handleDone() {
+ // Attempt to complete the current token token
+ performCompletion();
+
+ // Hide the keyboard
+ InputMethodManager imm = (InputMethodManager) getContext().getSystemService(
+ Context.INPUT_METHOD_SERVICE);
+ imm.hideSoftInputFromWindow(getWindowToken(), 0);
+ }
+
+ @Override
+ public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) {
+ boolean handled = super.onKeyUp(keyCode, event);
+ if (shouldFocusNext) {
+ shouldFocusNext = false;
+ handleDone();
+ }
+ return handled;
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, @NonNull KeyEvent event) {
+ boolean handled = false;
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_TAB:
+ case KeyEvent.KEYCODE_ENTER:
+ case KeyEvent.KEYCODE_DPAD_CENTER:
+ if (event.hasNoModifiers()) {
+ shouldFocusNext = true;
+ handled = true;
+ }
+ break;
+ case KeyEvent.KEYCODE_DEL:
+ handled = !canDeleteSelection(1) || deleteSelectedObject(false);
+ break;
+ }
+
+ return handled || super.onKeyDown(keyCode, event);
+ }
+
+ private boolean deleteSelectedObject(boolean handled) {
+ if (tokenClickStyle != null && tokenClickStyle.isSelectable()) {
+ Editable text = getText();
+ if (text == null) return handled;
+
+ TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class);
+ for (TokenImageSpan span : spans) {
+ if (span.view.isSelected()) {
+ removeSpan(span);
+ handled = true;
+ break;
+ }
+ }
+ }
+ return handled;
+ }
+
+ @Override
+ public boolean onEditorAction(TextView view, int action, KeyEvent keyEvent) {
+ if (action == EditorInfo.IME_ACTION_DONE) {
+ handleDone();
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onTouchEvent(@NonNull MotionEvent event) {
+ int action = event.getActionMasked();
+ Editable text = getText();
+ boolean handled = false;
+
+ if (tokenClickStyle == TokenClickStyle.None) {
+ handled = super.onTouchEvent(event);
+ }
+
+ if (isFocused() && text != null && lastLayout != null && action == MotionEvent.ACTION_UP) {
+
+ int offset = getOffsetForPosition(event.getX(), event.getY());
+
+ if (offset != -1) {
+ TokenImageSpan[] links = text.getSpans(offset, offset, TokenImageSpan.class);
+
+ if (links.length > 0) {
+ links[0].onClick();
+ handled = true;
+ } else {
+ //We didn't click on a token, so if any are selected, we should clear that
+ clearSelections();
+ }
+ }
+ }
+
+ if (!handled && tokenClickStyle != TokenClickStyle.None) {
+ handled = super.onTouchEvent(event);
+ }
+ return handled;
+
+ }
+
+ @Override
+ protected void onSelectionChanged(int selStart, int selEnd) {
+ if (hintVisible) {
+ //Don't let users select the hint
+ selStart = 0;
+ }
+ //Never let users select text
+ selEnd = selStart;
+
+ if (tokenClickStyle != null && tokenClickStyle.isSelectable()) {
+ Editable text = getText();
+ if (text != null) {
+ clearSelections();
+ }
+ }
+
+
+ if (prefix != null && (selStart < prefix.length() || selEnd < prefix.length())) {
+ //Don't let users select the prefix
+ setSelection(prefix.length());
+ } else {
+ Editable text = getText();
+ if (text != null) {
+ //Make sure if we are in a span, we select the spot 1 space after the span end
+ TokenImageSpan[] spans = text.getSpans(selStart, selEnd, TokenImageSpan.class);
+ for (TokenImageSpan span : spans) {
+ int spanEnd = text.getSpanEnd(span);
+ if (selStart <= spanEnd && text.getSpanStart(span) < selStart) {
+ if (spanEnd == text.length())
+ setSelection(spanEnd);
+ else
+ setSelection(spanEnd + 1);
+ return;
+ }
+ }
+
+ }
+
+ super.onSelectionChanged(selStart, selEnd);
+ }
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+ lastLayout = getLayout(); //Used for checking text positions
+ }
+
+ /**
+ * Collapse the view by removing all the tokens not on the first line. Displays a "+x" token.
+ * Restores the hidden tokens when the view gains focus.
+ *
+ * @param hasFocus boolean indicating whether we have the focus or not.
+ */
+ public void performCollapse(boolean hasFocus) {
+ // Pause the spanwatcher
+ focusChanging = true;
+ if (!hasFocus) {
+ Editable text = getText();
+ if (text != null && lastLayout != null) {
+ // Display +x thingy if appropriate
+ int lastPosition = lastLayout.getLineVisibleEnd(0);
+ TokenImageSpan[] tokens = text.getSpans(0, lastPosition, TokenImageSpan.class);
+ int count = objects.size() - tokens.length;
+
+ // Make sure we don't add more than 1 CountSpan
+ CountSpan[] countSpans = text.getSpans(0, lastPosition, CountSpan.class);
+
+ if (count > 0 && countSpans.length == 0) {
+ lastPosition++;
+ CountSpan cs = new CountSpan(count, getContext(), getCurrentTextColor(),
+ (int) getTextSize(), (int) maxTextWidth());
+ text.insert(lastPosition, cs.text);
+
+ float newWidth = Layout.getDesiredWidth(text, 0,
+ lastPosition + cs.text.length(), lastLayout.getPaint());
+ //If the +x span will be moved off screen, move it one token in
+ if (newWidth > maxTextWidth()) {
+ text.delete(lastPosition, lastPosition + cs.text.length());
+
+ if (tokens.length > 0) {
+ TokenImageSpan token = tokens[tokens.length - 1];
+ lastPosition = text.getSpanStart(token);
+ cs.setCount(count + 1);
+ } else {
+ lastPosition = prefix.length();
+ }
+
+ text.insert(lastPosition, cs.text);
+ }
+
+ text.setSpan(cs, lastPosition, lastPosition + cs.text.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+
+ // Remove all spans behind the count span and hold them in the hiddenSpans List
+ // The generic type information is not captured in TokenImageSpan.class so we have
+ // to perform a cast for the returned spans to coerce them to the proper generic type.
+ hiddenSpans = new ArrayList<>(Arrays.asList(
+ (TokenImageSpan[]) text.getSpans(lastPosition + cs.text.length(), text.length(), TokenImageSpan.class)));
+ for (TokenImageSpan span : hiddenSpans) {
+ removeSpan(span);
+ }
+ }
+ }
+ } else {
+ final Editable text = getText();
+ if (text != null) {
+ CountSpan[] counts = text.getSpans(0, text.length(), CountSpan.class);
+ for (CountSpan count : counts) {
+ text.delete(text.getSpanStart(count), text.getSpanEnd(count));
+ text.removeSpan(count);
+ }
+
+ // Restore the spans we have hidden
+ for (TokenImageSpan span : hiddenSpans) {
+ insertSpan(span);
+ }
+ hiddenSpans.clear();
+
+ if (hintVisible) {
+ setSelection(prefix.length());
+ } else {
+ // Slightly delay moving the cursor to the end. Inserting spans seems to take
+ // some time. (ugly, but what can you do :( )
+ postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ setSelection(text.length());
+ }
+ }, 10);
+ }
+
+ TokenSpanWatcher[] watchers = getText().getSpans(0, getText().length(), TokenSpanWatcher.class);
+ if (watchers.length == 0) {
+ //Someone removes watchers? I'm pretty sure this isn't in this code... -mgod
+ text.setSpan(spanWatcher, 0, text.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+ }
+ }
+ }
+ // Start the spanwatcher
+ focusChanging = false;
+ }
+
+ @Override
+ public void onFocusChanged(boolean hasFocus, int direction, Rect previous) {
+ super.onFocusChanged(hasFocus, direction, previous);
+
+ // See if the user left any unfinished tokens and finish them
+ if (!hasFocus) performCompletion();
+
+ // Clear sections when focus changes to avoid a token remaining selected
+ clearSelections();
+
+ // Collapse the view to a single line
+ if (allowCollapse) performCollapse(hasFocus);
+ }
+
+ @SuppressWarnings("unchecked cast")
+ @Override
+ protected CharSequence convertSelectionToString(Object object) {
+ selectedObject = (T) object;
+
+ //if the token gets deleted, this text will get put in the field instead
+ switch (deletionStyle) {
+ case Clear:
+ return "";
+ case PartialCompletion:
+ return currentCompletionText();
+ case ToString:
+ return object != null ? object.toString() : "";
+ case _Parent:
+ default:
+ return super.convertSelectionToString(object);
+
+ }
+ }
+
+ private SpannableStringBuilder buildSpannableForText(CharSequence text) {
+ //Add a sentinel , at the beginning so the user can remove an inner token and keep auto-completing
+ //This is a hack to work around the fact that the tokenizer cannot directly detect spans
+ //We don't want a space as the sentinel, and splitChar[0] is guaranteed to be something non-space
+ char sentinel = splitChar[0];
+ return new SpannableStringBuilder(String.valueOf(sentinel) + tokenizer.terminateToken(text));
+ }
+
+ protected TokenImageSpan buildSpanForObject(T obj) {
+ if (obj == null) {
+ return null;
+ }
+ View tokenView = getViewForObject(obj);
+ return new TokenImageSpan(tokenView, obj, (int) maxTextWidth());
+ }
+
+ @Override
+ protected void replaceText(CharSequence text) {
+ clearComposingText();
+
+ // Don't build a token for an empty String
+ if (selectedObject == null || selectedObject.toString().equals("")) return;
+
+ SpannableStringBuilder ssb = buildSpannableForText(text);
+ TokenImageSpan tokenSpan = buildSpanForObject(selectedObject);
+
+ Editable editable = getText();
+ int cursorPosition = getSelectionEnd();
+ int end = cursorPosition;
+ int start = cursorPosition;
+ if (!hintVisible) {
+ //If you force the drop down to show when the hint is visible, you can run a completion
+ //on the hint. If the hint includes commas, this truncates and inserts the hint in the field
+ end = getCorrectedTokenEnd();
+ start = getCorrectedTokenBeginning(end);
+ }
+
+ String original = TextUtils.substring(editable, start, end);
+
+ if (editable != null) {
+ if (tokenSpan == null) {
+ editable.replace(start, end, "");
+ } else if (!allowDuplicates && objects.contains(tokenSpan.getToken())) {
+ editable.replace(start, end, "");
+ } else {
+ QwertyKeyListener.markAsReplaced(editable, start, end, original);
+ editable.replace(start, end, ssb);
+ editable.setSpan(tokenSpan, start, start + ssb.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+ }
+
+ @Override
+ public boolean extractText(@NonNull ExtractedTextRequest request, @NonNull ExtractedText outText) {
+ try {
+ return super.extractText(request, outText);
+ } catch (IndexOutOfBoundsException ignored) {
+ Log.d(TAG, "extractText hit IndexOutOfBoundsException. This may be normal.", ignored);
+ return false;
+ }
+ }
+
+ /**
+ * Append a token object to the object list
+ *
+ * @param object the object to add to the displayed tokens
+ * @param sourceText the text used if this object is deleted
+ */
+ public void addObject(final T object, final CharSequence sourceText) {
+ post(new Runnable() {
+ @Override
+ public void run() {
+ if (object == null) return;
+ if (!allowDuplicates && objects.contains(object)) return;
+ if (tokenLimit != -1 && objects.size() == tokenLimit) return;
+ insertSpan(object, sourceText);
+ if (getText() != null && isFocused()) setSelection(getText().length());
+ }
+ });
+ }
+
+ /**
+ * Shorthand for addObject(object, "")
+ *
+ * @param object the object to add to the displayed token
+ */
+ public void addObject(T object) {
+ addObject(object, "");
+ }
+
+ /**
+ * Remove an object from the token list. Will remove duplicates or do nothing if no object
+ * present in the view.
+ *
+ * @param object object to remove, may be null or not in the view
+ */
+ public void removeObject(final T object) {
+ post(new Runnable() {
+ @Override
+ public void run() {
+ //To make sure all the appropriate callbacks happen, we just want to piggyback on the
+ //existing code that handles deleting spans when the text changes
+ Editable text = getText();
+ if (text == null) return;
+
+ // If the object is currently hidden, remove it
+ ArrayList toRemove = new ArrayList<>();
+ for (TokenImageSpan span : hiddenSpans) {
+ if (span.getToken().equals(object)) {
+ toRemove.add(span);
+ }
+ }
+ for (TokenImageSpan span : toRemove) {
+ hiddenSpans.remove(span);
+ // Remove it from the state and fire the callback
+ spanWatcher.onSpanRemoved(text, span, 0, 0);
+ }
+
+ updateCountSpan();
+
+ // If the object is currently visible, remove it
+ TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class);
+ for (TokenImageSpan span : spans) {
+ if (span.getToken().equals(object)) {
+ removeSpan(span);
+ }
+ }
+ }
+ });
+ }
+
+ /**
+ * Set the count span the current number of hidden objects
+ */
+ private void updateCountSpan() {
+ Editable text = getText();
+ CountSpan[] counts = text.getSpans(0, text.length(), CountSpan.class);
+ int newCount = hiddenSpans.size();
+ for (CountSpan count : counts) {
+ if (newCount == 0) {
+ // No more hidden Objects: remove the CountSpan
+ text.delete(text.getSpanStart(count), text.getSpanEnd(count));
+ text.removeSpan(count);
+ } else {
+ // Update the CountSpan
+ count.setCount(hiddenSpans.size());
+ text.setSpan(count, text.getSpanStart(count), text.getSpanEnd(count), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+ }
+
+ /**
+ * Remove a span from the current EditText and fire the appropriate callback
+ *
+ * @param span TokenImageSpan to be removed
+ */
+ private void removeSpan(TokenImageSpan span) {
+ Editable text = getText();
+ if (text == null) return;
+
+ //If the spanwatcher has been removed, we need to also manually trigger onSpanRemoved
+ TokenSpanWatcher[] spans = text.getSpans(0, text.length(), TokenSpanWatcher.class);
+ if (spans.length == 0) {
+ spanWatcher.onSpanRemoved(text, span, text.getSpanStart(span), text.getSpanEnd(span));
+ }
+
+ //Add 1 to the end because we put a " " at the end of the spans when adding them
+ text.delete(text.getSpanStart(span), text.getSpanEnd(span) + 1);
+
+ if (allowCollapse && !isFocused()) {
+ updateCountSpan();
+ }
+ }
+
+ /**
+ * Insert a new span for an Object
+ *
+ * @param object Object to create a span for
+ * @param sourceText CharSequence to show when the span is removed
+ */
+ private void insertSpan(T object, CharSequence sourceText) {
+ SpannableStringBuilder ssb = buildSpannableForText(sourceText);
+ TokenImageSpan tokenSpan = buildSpanForObject(object);
+
+ Editable editable = getText();
+ if (editable == null) return;
+
+ // If we're focused, or haven't hidden any objects yet, we can try adding it
+ if (!allowCollapse || isFocused() || hiddenSpans.isEmpty()) {
+ int offset = editable.length();
+ //There might be a hint visible...
+ if (hintVisible) {
+ //...so we need to put the object in in front of the hint
+ offset = prefix.length();
+ editable.insert(offset, ssb);
+ } else {
+ String completionText = currentCompletionText();
+ if (completionText != null && completionText.length() > 0) {
+ // The user has entered some text that has not yet been tokenized.
+ // Find the beginning of this text and insert the new token there.
+ offset = TextUtils.indexOf(editable, completionText);
+ }
+ editable.insert(offset, ssb);
+ }
+ editable.setSpan(tokenSpan, offset, offset + ssb.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+
+ // If we're not focused: collapse the view if necessary
+ if (!isFocused() && allowCollapse) performCollapse(false);
+
+ //In some cases, particularly the 1 to nth objects when not focused and restoring
+ //onSpanAdded doesn't get called
+ if (!objects.contains(object)) {
+ spanWatcher.onSpanAdded(editable, tokenSpan, 0, 0);
+ }
+ } else {
+ hiddenSpans.add(tokenSpan);
+ //Need to manually call onSpanAdded here as we're not putting the span on the text
+ spanWatcher.onSpanAdded(editable, tokenSpan, 0, 0);
+ updateCountSpan();
+ }
+ }
+
+ private void insertSpan(T object) {
+ String spanString;
+ // The information about the original text is lost here, so other than "toString" we have no data
+ if (deletionStyle == TokenDeleteStyle.ToString) {
+ spanString = object != null ? object.toString() : "";
+ } else {
+ spanString = "";
+ }
+
+ insertSpan(object, spanString);
+ }
+
+ private void insertSpan(TokenImageSpan span) {
+ insertSpan(span.getToken());
+ }
+
+ /**
+ * Remove all objects from the token list.
+ * We're handling this separately because removeObject doesn't always reliably trigger
+ * onSpanRemoved when called too fast.
+ * If removeObject is working for you, you probably shouldn't be using this.
+ */
+ @SuppressWarnings("unused")
+ public void clear() {
+ post(new Runnable() {
+ @Override
+ public void run() {
+ // If there's no text, we're already empty
+ Editable text = getText();
+ if (text == null) return;
+
+ // Get all spans in the EditText and remove them
+ TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class);
+ for (TokenImageSpan span : spans) {
+ removeSpan(span);
+
+ // Make sure the callback gets called
+ spanWatcher.onSpanRemoved(text, span, text.getSpanStart(span), text.getSpanEnd(span));
+ }
+ }
+ });
+ }
+
+ private void updateHint() {
+ Editable text = getText();
+ CharSequence hintText = getHint();
+ if (text == null || hintText == null) {
+ return;
+ }
+
+ //Show hint if we need to
+ if (prefix.length() > 0) {
+ HintSpan[] hints = text.getSpans(0, text.length(), HintSpan.class);
+ HintSpan hint = null;
+ int testLength = prefix.length();
+ if (hints.length > 0) {
+ hint = hints[0];
+ testLength += text.getSpanEnd(hint) - text.getSpanStart(hint);
+ }
+
+ if (text.length() == testLength) {
+ hintVisible = true;
+
+ if (hint != null) {
+ return;//hint already visible
+ }
+
+ //We need to display the hint manually
+ Typeface tf = getTypeface();
+ int style = Typeface.NORMAL;
+ if (tf != null) {
+ style = tf.getStyle();
+ }
+ ColorStateList colors = getHintTextColors();
+
+ HintSpan hintSpan = new HintSpan(null, style, (int) getTextSize(), colors, colors);
+ text.insert(prefix.length(), hintText);
+ text.setSpan(hintSpan, prefix.length(), prefix.length() + getHint().length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ setSelection(prefix.length());
+ } else {
+ if (hint == null) {
+ return; //hint already removed
+ }
+
+ //Remove the hint. There should only ever be one
+ int sStart = text.getSpanStart(hint);
+ int sEnd = text.getSpanEnd(hint);
+
+ text.removeSpan(hint);
+ text.replace(sStart, sEnd, "");
+
+ hintVisible = false;
+ }
+ }
+ }
+
+ private void clearSelections() {
+ if (tokenClickStyle == null || !tokenClickStyle.isSelectable()) return;
+
+ Editable text = getText();
+ if (text == null) return;
+
+ TokenImageSpan[] tokens = text.getSpans(0, text.length(), TokenImageSpan.class);
+ for (TokenImageSpan token : tokens) {
+ token.view.setSelected(false);
+ }
+ invalidate();
+ }
+
+ protected class TokenImageSpan extends ViewSpan implements NoCopySpan {
+ private T token;
+
+ public TokenImageSpan(View d, T token, int maxWidth) {
+ super(d, maxWidth);
+ this.token = token;
+ }
+
+ public T getToken() {
+ return this.token;
+ }
+
+ public void onClick() {
+ Editable text = getText();
+ if (text == null) return;
+
+ switch (tokenClickStyle) {
+ case Select:
+ case SelectDeselect:
+
+ if (!view.isSelected()) {
+ clearSelections();
+ view.setSelected(true);
+ break;
+ }
+
+ if (tokenClickStyle == TokenClickStyle.SelectDeselect || !isTokenRemovable(token)) {
+ view.setSelected(false);
+ invalidate();
+ break;
+ }
+ //If the view is already selected, we want to delete it
+ case Delete:
+ if (isTokenRemovable(token)) {
+ removeSpan(this);
+ }
+ break;
+ case None:
+ default:
+ if (getSelectionStart() != text.getSpanEnd(this) + 1) {
+ //Make sure the selection is not in the middle of the span
+ setSelection(text.getSpanEnd(this) + 1);
+ }
+ }
+ }
+ }
+
+ public interface TokenListener {
+ void onTokenAdded(T token);
+
+ void onTokenRemoved(T token);
+ }
+
+ private class TokenSpanWatcher implements SpanWatcher {
+
+ @SuppressWarnings("unchecked cast")
+ @Override
+ public void onSpanAdded(Spannable text, Object what, int start, int end) {
+ if (what instanceof TokenCompleteTextView>.TokenImageSpan && !savingState && !focusChanging) {
+ TokenImageSpan token = (TokenImageSpan) what;
+ objects.add(token.getToken());
+
+ if (listener != null)
+ listener.onTokenAdded(token.getToken());
+ }
+ }
+
+ @SuppressWarnings("unchecked cast")
+ @Override
+ public void onSpanRemoved(Spannable text, Object what, int start, int end) {
+ if (what instanceof TokenCompleteTextView>.TokenImageSpan && !savingState && !focusChanging) {
+ TokenImageSpan token = (TokenImageSpan) what;
+ if (objects.contains(token.getToken())) {
+ objects.remove(token.getToken());
+ }
+
+ if (listener != null)
+ listener.onTokenRemoved(token.getToken());
+ }
+ }
+
+ @Override
+ public void onSpanChanged(Spannable text, Object what, int ostart, int oend, int nstart, int nend) {
+ }
+ }
+
+ /**
+ * deletes tokens if you delete the space in front of them
+ * without this, you get the auto-complete dropdown a character early
+ */
+ private class TokenTextWatcher implements TextWatcher {
+ ArrayList spansToRemove = new ArrayList<>();
+
+ protected void removeToken(TokenImageSpan token, Editable text) {
+ text.removeSpan(token);
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ // count > 0 means something will be deleted
+ if (count > 0 && getText() != null) {
+ Editable text = getText();
+ int end = start + count;
+
+ //If we're deleting a space, we want spans from 1 character before this start
+ if (text.charAt(start) == ' ') {
+ start -= 1;
+ }
+
+ TokenImageSpan[] spans = text.getSpans(start, end, TokenImageSpan.class);
+
+ //NOTE: I'm not completely sure this won't cause problems if we get stuck in a text changed loop
+ //but it appears to work fine. Spans will stop getting removed if this breaks.
+ ArrayList spansToRemove = new ArrayList<>();
+ for (TokenImageSpan token : spans) {
+ if (text.getSpanStart(token) < end && start < text.getSpanEnd(token)) {
+ spansToRemove.add(token);
+ }
+ }
+ this.spansToRemove = spansToRemove;
+ }
+ }
+
+ @Override
+ public void afterTextChanged(Editable text) {
+ ArrayList spansCopy = new ArrayList<>(spansToRemove);
+ spansToRemove.clear();
+ for (TokenImageSpan token : spansCopy) {
+ int spanStart = text.getSpanStart(token);
+ int spanEnd = text.getSpanEnd(token);
+
+ removeToken(token, text);
+
+ //The end of the span is the character index after it
+ spanEnd--;
+
+ //Delete any extra split chars
+ if (spanEnd >= 0 && isSplitChar(text.charAt(spanEnd))) {
+ text.delete(spanEnd, spanEnd + 1);
+ }
+
+ if (spanStart >= 0 && isSplitChar(text.charAt(spanStart))) {
+ text.delete(spanStart, spanStart + 1);
+ }
+ }
+
+ clearSelections();
+ updateHint();
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+ }
+
+ protected ArrayList getSerializableObjects() {
+ ArrayList serializables = new ArrayList<>();
+ for (Object obj : getObjects()) {
+ if (obj instanceof Serializable) {
+ serializables.add((Serializable) obj);
+ } else {
+ Log.e(TAG, "Unable to save '" + obj + "'");
+ }
+ }
+ if (serializables.size() != objects.size()) {
+ String message = "You should make your objects Serializable or override\n" +
+ "getSerializableObjects and convertSerializableArrayToObjectArray";
+ Log.e(TAG, message);
+ }
+
+ return serializables;
+ }
+
+ @SuppressWarnings("unchecked")
+ protected ArrayList convertSerializableArrayToObjectArray(ArrayList s) {
+ return (ArrayList) (ArrayList) s;
+ }
+
+ @Override
+ public Parcelable onSaveInstanceState() {
+ ArrayList baseObjects = getSerializableObjects();
+
+ //We don't want to save the listeners as part of the parent
+ //onSaveInstanceState, so remove them first
+ removeListeners();
+
+ //ARGH! Apparently, saving the parent state on 2.3 mutates the spannable
+ //prevent this mutation from triggering add or removes of token objects ~mgod
+ savingState = true;
+ Parcelable superState = super.onSaveInstanceState();
+ savingState = false;
+ SavedState state = new SavedState(superState);
+
+ state.prefix = prefix;
+ state.allowCollapse = allowCollapse;
+ state.allowDuplicates = allowDuplicates;
+ state.performBestGuess = performBestGuess;
+ state.tokenClickStyle = tokenClickStyle;
+ state.tokenDeleteStyle = deletionStyle;
+ state.baseObjects = baseObjects;
+ state.splitChar = splitChar;
+
+ //So, when the screen is locked or some other system event pauses execution,
+ //onSaveInstanceState gets called, but it won't restore state later because the
+ //activity is still in memory, so make sure we add the listeners again
+ //They should not be restored in onInstanceState if the app is actually killed
+ //as we removed them before the parent saved instance state, so our adding them in
+ //onRestoreInstanceState is good.
+ addListeners();
+
+ return state;
+ }
+
+ @Override
+ public void onRestoreInstanceState(Parcelable state) {
+ if (!(state instanceof SavedState)) {
+ super.onRestoreInstanceState(state);
+ return;
+ }
+
+ SavedState ss = (SavedState) state;
+ super.onRestoreInstanceState(ss.getSuperState());
+
+ setText(ss.prefix);
+ prefix = ss.prefix;
+ updateHint();
+ allowCollapse = ss.allowCollapse;
+ allowDuplicates = ss.allowDuplicates;
+ performBestGuess = ss.performBestGuess;
+ tokenClickStyle = ss.tokenClickStyle;
+ deletionStyle = ss.tokenDeleteStyle;
+ splitChar = ss.splitChar;
+
+ addListeners();
+ for (T obj : convertSerializableArrayToObjectArray(ss.baseObjects)) {
+ addObject(obj);
+ }
+
+ // Collapse the view if necessary
+ if (!isFocused() && allowCollapse) {
+ post(new Runnable() {
+ @Override
+ public void run() {
+ //Resize the view and display the +x if appropriate
+ performCollapse(isFocused());
+ }
+ });
+ }
+ }
+
+ /**
+ * Handle saving the token state
+ */
+ private static class SavedState extends BaseSavedState {
+ CharSequence prefix;
+ boolean allowCollapse;
+ boolean allowDuplicates;
+ boolean performBestGuess;
+ TokenClickStyle tokenClickStyle;
+ TokenDeleteStyle tokenDeleteStyle;
+ ArrayList baseObjects;
+ char[] splitChar;
+
+ @SuppressWarnings("unchecked")
+ SavedState(Parcel in) {
+ super(in);
+ prefix = TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in);
+ allowCollapse = in.readInt() != 0;
+ allowDuplicates = in.readInt() != 0;
+ performBestGuess = in.readInt() != 0;
+ tokenClickStyle = TokenClickStyle.values()[in.readInt()];
+ tokenDeleteStyle = TokenDeleteStyle.values()[in.readInt()];
+ baseObjects = (ArrayList) in.readSerializable();
+ splitChar = in.createCharArray();
+ }
+
+ SavedState(Parcelable superState) {
+ super(superState);
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel out, int flags) {
+ super.writeToParcel(out, flags);
+ TextUtils.writeToParcel(prefix, out, 0);
+ out.writeInt(allowCollapse ? 1 : 0);
+ out.writeInt(allowDuplicates ? 1 : 0);
+ out.writeInt(performBestGuess ? 1 : 0);
+ out.writeInt(tokenClickStyle.ordinal());
+ out.writeInt(tokenDeleteStyle.ordinal());
+ out.writeSerializable(baseObjects);
+ out.writeCharArray(splitChar);
+ }
+
+ @Override
+ public String toString() {
+ String str = "TokenCompleteTextView.SavedState{"
+ + Integer.toHexString(System.identityHashCode(this))
+ + " tokens=" + baseObjects;
+ return str + "}";
+ }
+
+ @SuppressWarnings("hiding")
+ public static final Parcelable.Creator CREATOR
+ = new Parcelable.Creator() {
+ public SavedState createFromParcel(Parcel in) {
+ return new SavedState(in);
+ }
+
+ public SavedState[] newArray(int size) {
+ return new SavedState[size];
+ }
+ };
+ }
+
+ /**
+ * Checks if selection can be deleted. This method is called from TokenInputConnection .
+ * @param beforeLength the number of characters before the current selection end to check
+ * @return true if there are no non-deletable pieces of the section
+ */
+ @SuppressWarnings("unused")
+ public boolean canDeleteSelection(int beforeLength) {
+ if (objects.size() < 1) return true;
+
+ // if beforeLength is 1, we either have no selection or the call is coming from OnKey Event.
+ // In these scenarios, getSelectionStart() will return the correct value.
+
+ int endSelection = getSelectionEnd();
+ int startSelection = beforeLength == 1 ? getSelectionStart() : endSelection - beforeLength;
+
+ Editable text = getText();
+ TokenImageSpan[] spans = text.getSpans(0, text.length(), TokenImageSpan.class);
+
+ // Iterate over all tokens and allow the deletion
+ // if there are no tokens not removable in the selection
+ for (TokenImageSpan span : spans) {
+ int startTokenSelection = text.getSpanStart(span);
+ int endTokenSelection = text.getSpanEnd(span);
+
+ // moving on, no need to check this token
+ if (isTokenRemovable(span.token)) continue;
+
+ if (startSelection == endSelection) {
+ // Delete single
+ if (endTokenSelection + 1 == endSelection) {
+ return false;
+ }
+ } else {
+ // Delete range
+ // Don't delete if a non removable token is in range
+ if (startSelection <= startTokenSelection
+ && endTokenSelection + 1 <= endSelection) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ private class TokenInputConnection extends InputConnectionWrapper {
+
+ public TokenInputConnection(InputConnection target, boolean mutable) {
+ super(target, mutable);
+ }
+
+ // This will fire if the soft keyboard delete key is pressed.
+ // The onKeyPressed method does not always do this.
+ @Override
+ public boolean deleteSurroundingText(int beforeLength, int afterLength) {
+ // Shouldn't be able to delete any text with tokens that are not removable
+ if (!canDeleteSelection(beforeLength)) return false;
+
+ //Shouldn't be able to delete prefix, so don't do anything
+ if (getSelectionStart() <= prefix.length()) {
+ beforeLength = 0;
+ return deleteSelectedObject(false) || super.deleteSurroundingText(beforeLength, afterLength);
+ }
+
+ return super.deleteSurroundingText(beforeLength, afterLength);
+ }
+ }
+}
diff --git a/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/ViewSpan.java b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/ViewSpan.java
new file mode 100644
index 000000000..e0dd905d2
--- /dev/null
+++ b/fluentui_peoplepicker/src/main/java/com/microsoft/fluentui/tokenautocomplete/ViewSpan.java
@@ -0,0 +1,65 @@
+package com.microsoft.fluentui.tokenautocomplete;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.text.style.ReplacementSpan;
+import android.view.View;
+import android.view.ViewGroup;
+import androidx.annotation.NonNull;
+
+/**
+ * Span that holds a view it draws when rendering
+ *
+ * Created on 2/3/15.
+ * @author mgod
+ */
+public class ViewSpan extends ReplacementSpan {
+ protected View view;
+ private int maxWidth;
+
+ public ViewSpan(View v, int maxWidth) {
+ super();
+ this.maxWidth = maxWidth;
+ view = v;
+ view.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+ }
+
+ private void prepView() {
+ int widthSpec = View.MeasureSpec.makeMeasureSpec(maxWidth, View.MeasureSpec.AT_MOST);
+ int heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
+
+ view.measure(widthSpec, heightSpec);
+ view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight());
+ }
+
+ public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
+ prepView();
+
+ canvas.save();
+ //Centering the token looks like a better strategy that aligning the bottom
+ int padding = (bottom - top - view.getBottom()) / 2;
+ canvas.translate(x, bottom - view.getBottom() - padding);
+ view.draw(canvas);
+ canvas.restore();
+ }
+
+ public int getSize(@NonNull Paint paint, CharSequence charSequence, int i, int i2, Paint.FontMetricsInt fm) {
+ prepView();
+
+ if (fm != null) {
+ //We need to make sure the layout allots enough space for the view
+ int height = view.getMeasuredHeight();
+ int need = height - (fm.descent - fm.ascent);
+ if (need > 0) {
+ int ascent = need / 2;
+ //This makes sure the text drawing area will be tall enough for the view
+ fm.descent += need - ascent;
+ fm.ascent -= ascent;
+ fm.bottom += need - ascent;
+ fm.top -= need / 2;
+ }
+ }
+
+ return view.getRight();
+ }
+}
diff --git a/fluentui_peoplepicker/src/main/res/drawable/people_picker_textview_focusable_background.xml b/fluentui_peoplepicker/src/main/res/drawable/people_picker_textview_focusable_background.xml
new file mode 100644
index 000000000..1c494cbf9
--- /dev/null
+++ b/fluentui_peoplepicker/src/main/res/drawable/people_picker_textview_focusable_background.xml
@@ -0,0 +1,13 @@
+
+
+ -
+
+
+
+
+
\ No newline at end of file
diff --git a/fluentui_persona/build.gradle b/fluentui_persona/build.gradle
index dcc0777a9..97a409aaf 100644
--- a/fluentui_persona/build.gradle
+++ b/fluentui_persona/build.gradle
@@ -59,6 +59,9 @@ android {
}
productFlavors {
}
+ lintOptions {
+ abortOnError false
+ }
}
diff --git a/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/Avatar.kt b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/Avatar.kt
index 1eac0e067..4aa4253cf 100644
--- a/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/Avatar.kt
+++ b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/Avatar.kt
@@ -10,9 +10,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.clearAndSetSemantics
@@ -29,6 +32,7 @@ import com.microsoft.fluentui.theme.token.ControlTokens
import com.microsoft.fluentui.theme.token.FluentIcon
import com.microsoft.fluentui.theme.token.Icon
import com.microsoft.fluentui.theme.token.controlTokens.*
+import com.microsoft.fluentui.util.dpToPx
// Tags used for testing
const val AVATAR_IMAGE = "Fluent Avatar Image"
@@ -44,6 +48,7 @@ const val AVATAR_ICON = "Fluent Avatar Icon"
* @param size Set Size of Avatar. Default: [AvatarSize.Size32]
* @param enableActivityRings Enable/Disable Activity Rings on Avatar
* @param enablePresence Enable/Disable Presence Indicator on Avatar, if cutout is provided then presence indicator is not displayed
+ * @param enableActivityDot Enable/Disable Activity Dot on Avatar.
* @param cutoutIconDrawable cutout drawable
* @param cutoutIconImageVector cutout image vector
* @param cutoutStyle shape of the cutout. Default: [CutoutStyle.Circle]
@@ -57,6 +62,7 @@ fun Avatar(
size: AvatarSize = AvatarSize.Size32,
enableActivityRings: Boolean = false,
enablePresence: Boolean = true,
+ enableActivityDot: Boolean = false,
@DrawableRes cutoutIconDrawable: Int? = null,
cutoutIconImageVector: ImageVector? = null,
cutoutStyle: CutoutStyle = CutoutStyle.Circle,
@@ -71,10 +77,15 @@ fun Avatar(
val personInitials = person.getInitials()
val avatarInfo = AvatarInfo(
- size, AvatarType.Person, person.isActive,
-
- person.status, person.isOOO, person.isImageAvailable(),
- personInitials.isNotEmpty(), person.getName(), cutoutStyle
+ size,
+ AvatarType.Person,
+ person.isActive,
+ person.status,
+ person.isOOO,
+ person.isImageAvailable(),
+ personInitials.isNotEmpty(),
+ person.getName(),
+ cutoutStyle
)
val avatarSize = token.avatarSize(avatarInfo)
val backgroundColor = token.backgroundBrush(avatarInfo)
@@ -82,23 +93,18 @@ fun Avatar(
val borders = token.borderStroke(avatarInfo)
val fontTextStyle = token.fontTypography(avatarInfo)
val cutoutCornerRadius = token.cutoutCornerRadius(avatarInfo)
- val cutoutBackgroundColor =
- token.cutoutBackgroundColor(avatarInfo = avatarInfo)
+ val cutoutBackgroundColor = token.cutoutBackgroundColor(avatarInfo = avatarInfo)
val cutoutBorderColor = token.cutoutBorderColor(avatarInfo = avatarInfo)
val cutoutIconSize = token.cutoutIconSize(avatarInfo = avatarInfo)
val isCutoutEnabled = (cutoutIconDrawable != null || cutoutIconImageVector != null)
var isImageOrInitialsAvailable = true
- Box(modifier = Modifier
- .semantics(mergeDescendants = true) {
- contentDescription = "${person.getName()}. " +
- "${if (enablePresence) "Status, ${person.status}," else ""} " +
- "${if (enablePresence && person.isOOO) "Out Of Office," else ""} " +
- if (enableActivityRings) {
- if (person.isActive) "Active" else "Inactive"
- } else ""
- }
- ) {
+ Box(modifier = Modifier.semantics(mergeDescendants = true) {
+ contentDescription =
+ "${person.getName()}. " + "${if (enablePresence) "Status, ${person.status}," else ""} " + "${if (enablePresence && person.isOOO) "Out Of Office," else ""} " + if (enableActivityRings) {
+ if (person.isActive) "Active" else "Inactive"
+ } else ""
+ }) {
Box(
Modifier
.then(modifier)
@@ -107,35 +113,35 @@ fun Avatar(
) {
when {
person.image != null -> {
- Image(
- painter = painterResource(person.image), null,
+ Image(painter = painterResource(person.image),
+ null,
+ contentScale = ContentScale.Crop,
modifier = Modifier
.size(avatarSize)
.clip(CircleShape)
.semantics {
testTag = AVATAR_IMAGE
- }
- )
+ })
}
+
person.bitmap != null -> {
- Image(
- bitmap = person.bitmap.asImageBitmap(), null,
+ Image(bitmap = person.bitmap.asImageBitmap(),
+ null,
+ contentScale = ContentScale.Crop,
modifier = Modifier
.size(avatarSize)
.clip(CircleShape)
.semantics {
testTag = AVATAR_IMAGE
- }
- )
+ })
}
+
personInitials.isNotEmpty() -> {
- BasicText(personInitials,
- style = fontTextStyle.merge(
- TextStyle(color = foregroundColor)
- ),
- modifier = Modifier
- .clearAndSetSemantics { })
+ BasicText(personInitials, style = fontTextStyle.merge(
+ TextStyle(color = foregroundColor)
+ ), modifier = Modifier.clearAndSetSemantics { })
}
+
else -> {
isImageOrInitialsAvailable = false
Icon(
@@ -151,8 +157,7 @@ fun Avatar(
}
}
- if (enableActivityRings)
- ActivityRing(radius = avatarSize / 2, borders)
+ if (enableActivityRings) ActivityRing(radius = avatarSize / 2, borders)
if (isCutoutEnabled && isImageOrInitialsAvailable && cutoutIconSize > 0.dp) {
Box(
@@ -164,30 +169,30 @@ fun Avatar(
if (cutoutIconDrawable != null) {
Image(
painter = painterResource(cutoutIconDrawable),
+ contentScale = ContentScale.Crop,
modifier = Modifier
.background(cutoutBackgroundColor)
.border(
- 2.dp,
- cutoutBorderColor,
- RoundedCornerShape(cutoutCornerRadius)
+ 2.dp, cutoutBorderColor, RoundedCornerShape(cutoutCornerRadius)
)
.padding(4.dp)
.size(cutoutIconSize),
- contentDescription = cutoutContentDescription
+ contentDescription = cutoutContentDescription,
+ colorFilter = token.cutoutColorFilter(avatarInfo = avatarInfo)
)
} else if (cutoutIconImageVector != null) {
Image(
imageVector = cutoutIconImageVector,
+ contentScale = ContentScale.Crop,
modifier = Modifier
.background(cutoutBackgroundColor)
.border(
- 2.dp,
- cutoutBorderColor,
- RoundedCornerShape(cutoutCornerRadius)
+ 2.dp, cutoutBorderColor, RoundedCornerShape(cutoutCornerRadius)
)
.padding(4.dp)
.size(cutoutIconSize),
- contentDescription = cutoutContentDescription
+ contentDescription = cutoutContentDescription,
+ colorFilter = token.cutoutColorFilter(avatarInfo = avatarInfo)
)
}
}
@@ -202,7 +207,89 @@ fun Avatar(
Modifier
.align(Alignment.BottomEnd)
// Adding 2.dp to both side to incorporate border which is an image in Fluent Android.
- .offset(presenceOffset.x + 2.dp, -presenceOffset.y + 2.dp)
+ .offset(presenceOffset.x + 2.dp, -presenceOffset.y + 2.dp),
+ contentScale = ContentScale.Crop
+ )
+ }
+
+ if (enableActivityDot) {
+ ActivityDot(token, avatarInfo, modifier.align(Alignment.TopEnd))
+ }
+ }
+ }
+}
+
+@Composable
+internal fun SlicedAvatar(
+ person: Person,
+ modifier: Modifier = Modifier,
+ width: Dp = 32.dp,
+ avatarToken: AvatarTokens? = null,
+ slicedAvatarSize: Dp = 32.dp,
+ size: AvatarSize = AvatarSize.Size32
+) {
+ val personInitials = person.getInitials()
+ // if less than 19dp, show only first initial
+ val personInitialsToDisplay =
+ if (personInitials.length >= 2 && width < 19.dp) personInitials[0].toString() else personInitials
+ val token = avatarToken
+ ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.AvatarControlType] as AvatarTokens
+ val avatarInfo = AvatarInfo(
+ size = size,
+ type = AvatarType.Person,
+ isImageAvailable = person.isImageAvailable(),
+ hasValidInitials = personInitials.isNotEmpty(),
+ calculatedColorKey = person.getName()
+ )
+ val foregroundColor = token.foregroundColor(avatarInfo)
+ val fontTextStyle = fontTypographyForSlicedAvatar(slicedAvatarSize)
+ val backgroundBrush = token.backgroundBrush(avatarInfo)
+ when {
+ person.image != null -> {
+ Image(
+ painter = painterResource(person.image),
+ null,
+ contentScale = ContentScale.Crop,
+ modifier = modifier
+ )
+ }
+
+ person.bitmap != null -> {
+ Image(
+ bitmap = person.bitmap.asImageBitmap(),
+ null,
+ contentScale = ContentScale.Crop,
+ modifier = modifier
+ )
+ }
+
+ personInitialsToDisplay.isNotEmpty() -> {
+ Box(
+ modifier = modifier.background(
+ brush = backgroundBrush
+ ), contentAlignment = Alignment.Center
+ ) {
+ BasicText(personInitialsToDisplay, style = fontTextStyle.merge(
+ TextStyle(color = foregroundColor)
+ ), modifier = Modifier.clearAndSetSemantics { })
+ }
+ }
+
+ else -> {
+ Box(
+ modifier = modifier.background(
+ brush = backgroundBrush
+ ), contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ token.icon(avatarInfo),
+ null,
+ modifier = Modifier
+ .background(backgroundBrush, CircleShape)
+ .semantics {
+ testTag = AVATAR_ICON
+ },
+ tint = foregroundColor,
)
}
}
@@ -224,7 +311,7 @@ fun Avatar(
group: Group,
modifier: Modifier = Modifier,
size: AvatarSize = AvatarSize.Size32,
- avatarToken: AvatarTokens? = null,
+ avatarToken: AvatarTokens? = null
) {
val themeID =
@@ -233,7 +320,8 @@ fun Avatar(
?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.AvatarControlType] as AvatarTokens
val avatarInfo = AvatarInfo(
- size, AvatarType.Group,
+ size,
+ AvatarType.Group,
isImageAvailable = group.isImageAvailable(),
hasValidInitials = group.getInitials().isNotEmpty(),
calculatedColorKey = group.groupName
@@ -245,8 +333,7 @@ fun Avatar(
val foregroundColor = token.foregroundColor(avatarInfo)
var membersList = ""
- for (person in group.members)
- membersList += (person.firstName + person.lastName + "\n")
+ for (person in group.members) membersList += (person.firstName + person.lastName + "\n")
Box(
modifier
@@ -260,43 +347,37 @@ fun Avatar(
Modifier
.clip(RoundedCornerShape(cornerRadius))
.background(backgroundColor)
- .fillMaxSize(),
- contentAlignment = Alignment.Center
+ .fillMaxSize(), contentAlignment = Alignment.Center
) {
if (group.image != null) {
- Image(
- painter = painterResource(group.image),
+ Image(painter = painterResource(group.image),
+ contentScale = ContentScale.Crop,
contentDescription = null,
modifier = Modifier
.size(avatarSize)
.clip(RoundedCornerShape(cornerRadius))
.semantics {
testTag = AVATAR_IMAGE
- }
- )
+ })
} else if (group.bitmap != null) {
- Image(
- bitmap = group.bitmap.asImageBitmap(),
+ Image(bitmap = group.bitmap.asImageBitmap(),
+ contentScale = ContentScale.Crop,
contentDescription = null,
modifier = Modifier
.size(avatarSize)
.clip(RoundedCornerShape(cornerRadius))
.semantics {
testTag = AVATAR_IMAGE
- }
- )
+ })
} else if (group.groupName.isNotEmpty()) {
BasicText(group.getInitials(),
style = fontTextStyle.merge(TextStyle(color = foregroundColor)),
modifier = Modifier.clearAndSetSemantics { })
} else {
Icon(
- token.icon(avatarInfo),
- null,
- modifier = Modifier.semantics {
+ token.icon(avatarInfo), null, modifier = Modifier.semantics {
testTag = AVATAR_ICON
- },
- tint = foregroundColor
+ }, tint = foregroundColor
)
}
}
@@ -311,6 +392,7 @@ fun Avatar(
* @param size Set Size of Avatar. Default: [AvatarSize. Medium]
* @param enableActivityRings Enable/Disable Activity Rings on Avatar
* @param avatarToken Token to provide appearance values to Avatar
+ * @param enableActivityDot Enable/Disable Activity Dot on Avatar.
*/
@Composable
fun Avatar(
@@ -318,7 +400,8 @@ fun Avatar(
modifier: Modifier = Modifier,
size: AvatarSize = AvatarSize.Size32,
enableActivityRings: Boolean = false,
- avatarToken: AvatarTokens? = null
+ avatarToken: AvatarTokens? = null,
+ enableActivityDot: Boolean = false
) {
val themeID =
FluentTheme.themeID //Adding This only for recomposition in case of Token Updates. Unused otherwise.
@@ -349,8 +432,10 @@ fun Avatar(
modifier = Modifier.clearAndSetSemantics { })
}
- if (enableActivityRings)
- ActivityRing(radius = avatarSize / 2, borders)
+ if (enableActivityRings) ActivityRing(radius = avatarSize / 2, borders)
+ if (enableActivityDot) {
+ ActivityDot(token, avatarInfo, modifier.align(Alignment.TopEnd))
+ }
}
}
@@ -368,3 +453,29 @@ fun ActivityRing(radius: Dp, borders: List) {
}
}
}
+
+@Composable
+fun ActivityDot(token: AvatarTokens, avatarInfo: AvatarInfo, modifier: Modifier) {
+ val unreadDotOffset: DpOffset = token.unreadDotOffset(avatarInfo)
+ val unreadDotSize: Dp = token.unreadDotSize(avatarInfo)
+ val unreadDotBackground: Brush = token.unreadDotBackgroundBrush(avatarInfo)
+ val unreadDotBorderStroke = token.unreadDotBorderStroke(avatarInfo)
+ Box(
+ modifier = modifier
+ .size(unreadDotSize)
+ .offset(unreadDotOffset.x + unreadDotBorderStroke.width , -unreadDotOffset.y + unreadDotBorderStroke.width)
+ ) {
+ Canvas(Modifier) {
+ drawCircle(
+ brush = unreadDotBorderStroke.brush,
+ radius = dpToPx(unreadDotBorderStroke.width + unreadDotSize / 2)
+ )
+ drawCircle(
+ brush = unreadDotBackground,
+ style = Fill,
+ radius = dpToPx(unreadDotSize / 2)
+ )
+ }
+ }
+
+}
diff --git a/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/AvatarGroup.kt b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/AvatarGroup.kt
index c6e1c0368..7f2c6bbb1 100644
--- a/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/AvatarGroup.kt
+++ b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/AvatarGroup.kt
@@ -37,6 +37,7 @@ fun AvatarGroup(
style: AvatarGroupStyle = AvatarGroupStyle.Stack,
maxVisibleAvatar: Int = DEFAULT_MAX_AVATAR,
enablePresence: Boolean = false,
+ enableActivityDot: Boolean = false,
avatarToken: AvatarTokens? = null,
avatarGroupToken: AvatarGroupTokens? = null
) {
@@ -52,6 +53,8 @@ fun AvatarGroup(
else
maxVisibleAvatar
+ val showActivityDot: Boolean = enableActivityDot && style == AvatarGroupStyle.Stack
+
var enablePresence: Boolean = enablePresence
if (style == AvatarGroupStyle.Stack)
enablePresence = false
@@ -81,29 +84,52 @@ fun AvatarGroup(
Layout(modifier = modifier
.padding(8.dp)
.then(semanticModifier), content = {
- for (i in 0 until visibleAvatar) {
- val person = group.members[i]
+ if (group.members.size > 0) {
+ if (style == AvatarGroupStyle.Pie) {
+ if (visibleAvatar > 1) {
+ AvatarPie(
+ group = group,
+ size = size,
+ noOfVisibleAvatars = visibleAvatar,
+ avatarTokens = avatarToken
+ )
+ } else {
+ Avatar(
+ group.members[0],
+ size = size,
+ enableActivityRings = true,
+ enablePresence = enablePresence,
+ avatarToken = avatarToken
+ )
+ }
- var paddingModifier: Modifier = Modifier
- if (style == AvatarGroupStyle.Pile && person.isActive) {
- val padding = token.pilePadding(avatarGroupInfo)
- paddingModifier = paddingModifier.padding(start = padding, end = padding)
- }
+ } else {
+ for (i in 0 until visibleAvatar) {
+ val person = group.members[i]
- Avatar(
- person,
- modifier = paddingModifier,
- size = size,
- enableActivityRings = true,
- enablePresence = enablePresence,
- avatarToken = avatarToken
- )
- }
- if (group.members.size > visibleAvatar || group.members.isEmpty()) {
- Avatar(
- group.members.size - visibleAvatar, size = size,
- enableActivityRings = true, avatarToken = avatarToken
- )
+ var paddingModifier: Modifier = Modifier
+ if (style == AvatarGroupStyle.Pile && person.isActive) {
+ val padding = token.pilePadding(avatarGroupInfo)
+ paddingModifier = paddingModifier.padding(start = padding, end = padding)
+ }
+
+ Avatar(
+ person,
+ modifier = paddingModifier,
+ size = size,
+ enableActivityRings = true,
+ enablePresence = enablePresence,
+ avatarToken = avatarToken,
+ enableActivityDot = group.members.size == visibleAvatar && i == visibleAvatar - 1 && showActivityDot
+ )
+ }
+ if (group.members.size > visibleAvatar || group.members.isEmpty()) {
+ Avatar(
+ group.members.size - visibleAvatar, size = size,
+ enableActivityRings = true, avatarToken = avatarToken, enableActivityDot = showActivityDot
+ )
+ }
+ }
}
}) { measurables, constraints ->
val placeables = measurables.map { measurable ->
@@ -127,4 +153,5 @@ fun AvatarGroup(
}
}
}
-}
\ No newline at end of file
+}
+
diff --git a/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/AvatarPie.kt b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/AvatarPie.kt
new file mode 100644
index 000000000..95a640b8d
--- /dev/null
+++ b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/AvatarPie.kt
@@ -0,0 +1,155 @@
+package com.microsoft.fluentui.tokenized.persona
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.microsoft.fluentui.theme.FluentTheme
+import com.microsoft.fluentui.theme.token.ControlTokens
+import com.microsoft.fluentui.theme.token.controlTokens.AvatarInfo
+import com.microsoft.fluentui.theme.token.controlTokens.AvatarTokens
+import com.microsoft.fluentui.theme.token.controlTokens.AvatarSize
+
+private val SPACER_SIZE = 2.dp
+
+@Composable
+fun AvatarPie(
+ group: Group, size: AvatarSize, noOfVisibleAvatars: Int = 2, avatarTokens: AvatarTokens? = null
+) {
+ val avatarInfo = AvatarInfo(
+ size
+ )
+ val token = avatarTokens
+ ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.AvatarControlType] as AvatarTokens
+ val avatarSize = token.avatarSize(avatarInfo)
+
+ Box(
+ modifier = Modifier
+ .requiredSize(avatarSize)
+ .background(
+ color = Color.White, shape = CircleShape
+ ), contentAlignment = Alignment.Center
+ ) {
+ val slicedAvatarDimen = avatarSize / 2 - SPACER_SIZE / 2
+ if (noOfVisibleAvatars == 2) {
+ RenderTwoSlices(avatarSize, slicedAvatarDimen, group, size)
+ } else if (noOfVisibleAvatars >= 3) {
+ RenderThreeSlices(avatarSize, slicedAvatarDimen, group, size)
+ }
+ }
+}
+
+@Composable
+private fun RenderTwoSlices(
+ avatarSize: Dp, slicedAvatarDimen: Dp, group: Group, size: AvatarSize
+) {
+ Row(
+ modifier = Modifier
+ .requiredSize(avatarSize)
+ .clip(CircleShape)
+ ) {
+ SlicedAvatar(
+ group.members[0],
+ slicedAvatarSize = avatarSize,
+ width = slicedAvatarDimen,
+ modifier = Modifier
+ .height(avatarSize)
+ .width(slicedAvatarDimen),
+ size = size
+ )
+ AddVerticalSpacer()
+ SlicedAvatar(
+ group.members[1],
+ slicedAvatarSize = avatarSize,
+ width = slicedAvatarDimen,
+ modifier = Modifier
+ .height(avatarSize)
+ .width(slicedAvatarDimen),
+ size = size
+ )
+ }
+}
+
+@Composable
+private fun RenderThreeSlices(
+ avatarSize: Dp, slicedAvatarDimen: Dp, group: Group, size: AvatarSize
+) {
+ Row(
+ modifier = Modifier
+ .requiredSize(avatarSize)
+ .clip(CircleShape)
+ ) {
+ SlicedAvatar(
+ group.members[0],
+ slicedAvatarSize = avatarSize,
+ width = slicedAvatarDimen,
+ modifier = Modifier
+ .height(avatarSize)
+ .width(slicedAvatarDimen)
+ .align(Alignment.CenterVertically),
+ size = size
+ )
+ AddVerticalSpacer()
+ Column(
+ modifier = Modifier
+ .height(avatarSize)
+ .width(slicedAvatarDimen),
+ ) {
+ SlicedAvatar(
+ group.members[1],
+ slicedAvatarSize = slicedAvatarDimen,
+ width = slicedAvatarDimen,
+ modifier = Modifier
+ .height(slicedAvatarDimen)
+ .width(slicedAvatarDimen),
+ size = size
+ )
+ AddHorizontalSpacer()
+ SlicedAvatar(
+ group.members[2],
+ slicedAvatarSize = slicedAvatarDimen,
+ width = slicedAvatarDimen,
+ modifier = Modifier
+ .height(slicedAvatarDimen)
+ .width(slicedAvatarDimen),
+ size = size
+ )
+ }
+
+ }
+}
+
+@Composable
+private fun AddVerticalSpacer() {
+ Spacer(
+ modifier = Modifier
+ .background(color = Color.White)
+ .fillMaxHeight()
+ .width(SPACER_SIZE)
+ )
+}
+
+@Composable
+private fun AddHorizontalSpacer() {
+ Spacer(
+ modifier = Modifier
+ .background(color = Color.White)
+ .fillMaxWidth()
+ .height(SPACER_SIZE)
+ )
+}
diff --git a/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/Utils.kt b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/Utils.kt
index ffde47e45..d34510553 100644
--- a/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/Utils.kt
+++ b/fluentui_persona/src/main/java/com/microsoft/fluentui/tokenized/persona/Utils.kt
@@ -4,7 +4,11 @@ import android.graphics.Bitmap
import android.os.Parcelable
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Composable
-import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.microsoft.fluentui.theme.token.FluentGlobalTokens
import com.microsoft.fluentui.theme.token.controlTokens.AvatarSize
import com.microsoft.fluentui.theme.token.controlTokens.AvatarStatus
import kotlinx.parcelize.Parcelize
@@ -152,4 +156,89 @@ fun getAvatarSize(secondaryText: String?, tertiaryText: String?): AvatarSize {
return AvatarSize.Size40
}
return AvatarSize.Size56
+}
+
+
+@Composable
+fun fontTypographyForSlicedAvatar(slicedAvatarSize: Dp): TextStyle {
+ return when (slicedAvatarSize) {
+ 7.dp -> TextStyle(
+ fontSize = 4.sp,
+ lineHeight = 4.69.sp,
+ fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value
+ )
+ 9.dp -> TextStyle(
+ fontSize = 5.sp,
+ lineHeight = 9.sp,
+ fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value
+ )
+ 11.dp -> TextStyle(
+ fontSize = 6.sp,
+ lineHeight = 7.5.sp,
+ fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value
+ )
+ 15.dp -> TextStyle(
+ fontSize = 10.sp,
+ lineHeight = 13.sp,
+ fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value
+ )
+ 16.dp -> TextStyle(
+ fontSize = 6.sp,
+ lineHeight = 7.03.sp,
+ fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value
+ )
+ 19.dp -> TextStyle(
+ fontSize = 8.sp,
+ lineHeight = 15.sp,
+ fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value
+ )
+ 20.dp -> TextStyle(
+ fontSize = 8.sp,
+ lineHeight = 9.sp,
+ fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value
+ )
+ 24.dp -> TextStyle(
+ fontSize = 10.sp,
+ lineHeight = 9.sp,
+ fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value
+ )
+ 27.dp -> TextStyle(
+ fontSize = 11.sp,
+ lineHeight = 12.89.sp,
+ fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value
+ )
+ 32.dp -> TextStyle(
+ fontSize = 13.sp,
+ lineHeight = 13.sp,
+ fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value
+ )
+ 35.dp-> TextStyle(
+ fontSize = 13.sp,
+ lineHeight = 28.sp,
+ fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value
+ )
+ 40.dp -> TextStyle(
+ fontSize = 10.sp,
+ lineHeight = 15.sp,
+ fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value
+ )
+ 56.dp -> TextStyle(
+ fontSize = 14.sp,
+ lineHeight = 18.sp,
+ fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value
+ )
+ 72.dp -> TextStyle(
+ fontSize = FluentGlobalTokens.FontSizeTokens.Size400.value,
+ lineHeight = FluentGlobalTokens.LineHeightTokens.Size700.value,
+ fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value
+ )
+
+ else -> {
+ TextStyle(
+ fontSize = 13.sp,
+ lineHeight = 13.sp,
+ fontWeight = FluentGlobalTokens.FontWeightTokens.Regular.value
+ )
+ }
+ }
}
\ No newline at end of file
diff --git a/fluentui_progress/build.gradle b/fluentui_progress/build.gradle
index 50e9e2eda..9bdb0b224 100644
--- a/fluentui_progress/build.gradle
+++ b/fluentui_progress/build.gradle
@@ -37,6 +37,9 @@ android {
buildFeatures {
compose true
}
+ lintOptions {
+ abortOnError false
+ }
}
dependencies {
diff --git a/fluentui_progress/src/main/java/com/microsoft/fluentui/tokenized/shimmer/Shimmer.kt b/fluentui_progress/src/main/java/com/microsoft/fluentui/tokenized/shimmer/Shimmer.kt
index 395c27e1a..49017b836 100644
--- a/fluentui_progress/src/main/java/com/microsoft/fluentui/tokenized/shimmer/Shimmer.kt
+++ b/fluentui_progress/src/main/java/com/microsoft/fluentui/tokenized/shimmer/Shimmer.kt
@@ -1,6 +1,7 @@
package com.microsoft.fluentui.tokenized.shimmer
import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
@@ -15,6 +16,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
@@ -28,6 +31,7 @@ import androidx.compose.ui.unit.dp
import com.microsoft.fluentui.theme.FluentTheme
import com.microsoft.fluentui.theme.token.ControlTokens
import com.microsoft.fluentui.theme.token.controlTokens.ShimmerInfo
+import com.microsoft.fluentui.theme.token.controlTokens.ShimmerOrientation
import com.microsoft.fluentui.theme.token.controlTokens.ShimmerTokens
import com.microsoft.fluentui.util.dpToPx
import kotlin.math.absoluteValue
@@ -36,44 +40,42 @@ import kotlin.math.sqrt
private const val DEFAULT_CORNER_RADIUS = 4
/**
- * Create an empty Shimmer effect
- *
- * @param modifier Modifier for shimmer
- * @param shimmerTokens Token values for shimmer
- *
- */
-@Composable
-fun Shimmer(
- modifier: Modifier = Modifier,
- shimmerTokens: ShimmerTokens? = null
-) {
- InternalShimmer(
- cornerRadius = DEFAULT_CORNER_RADIUS.dp,
- modifier = modifier,
- shimmerTokens = shimmerTokens
- )
-}
-
-/**
- * Create Shimmer effect on some content
+ * Create Shimmer effect on some content, creates an empty shimmer if content not provided or left null
*
* @param cornerRadius Corner radius of the shimmer
* @param modifier Modifier for shimmer
* @param shimmerTokens Token values for shimmer
- * @param content Content to be shimmered
+ * @param content Content to be shimmered, creates an empty shimmer if content not provided or left null
*
*/
@Composable
fun Shimmer(
- cornerRadius: Dp,
+ cornerRadius: Dp = DEFAULT_CORNER_RADIUS.dp,
modifier: Modifier = Modifier,
shimmerTokens: ShimmerTokens? = null,
- content: @Composable () -> Unit,
+ shimmerDelay: Int = 1000,
+ isShimmering: Boolean = true,
+ shimmerOrientation: ShimmerOrientation = ShimmerOrientation.TOPLEFT_TO_BOTTOMRIGHT,
+ content: (@Composable () -> Unit)? = null,
) {
+ if(content == null) {
+ InternalShimmer(
+ cornerRadius = cornerRadius,
+ modifier = modifier,
+ shimmerTokens = shimmerTokens,
+ shimmerDelay = shimmerDelay,
+ isShimmering = isShimmering,
+ shimmerOrientation = shimmerOrientation
+ )
+ return
+ }
InternalShimmer(
cornerRadius = cornerRadius,
modifier = modifier,
- shimmerTokens = shimmerTokens
+ shimmerTokens = shimmerTokens,
+ shimmerDelay = shimmerDelay,
+ isShimmering = isShimmering,
+ shimmerOrientation = shimmerOrientation,
) {
content()
}
@@ -84,6 +86,9 @@ internal fun InternalShimmer(
cornerRadius: Dp,
modifier: Modifier = Modifier,
shimmerTokens: ShimmerTokens? = null,
+ shimmerDelay: Int = 1000,
+ isShimmering: Boolean = true,
+ shimmerOrientation: ShimmerOrientation = ShimmerOrientation.TOPLEFT_TO_BOTTOMRIGHT,
content: (@Composable () -> Unit)? = null,
) {
val themeID =
@@ -96,6 +101,18 @@ internal fun InternalShimmer(
val diagonal =
sqrt((screenHeight * screenHeight + screenWidth * screenWidth).toDouble()).toFloat()
val shimmerInfo = ShimmerInfo()
+ val cachedDelay = tokens.delay(shimmerInfo)
+ val shimmerDelayValue = if (cachedDelay != -1) {
+ cachedDelay
+ } else {
+ shimmerDelay
+ }
+ val tokenOrientation = tokens.orientation(shimmerInfo)
+ val orientation: ShimmerOrientation = if(tokenOrientation != ShimmerOrientation._NONE){
+ tokenOrientation
+ } else {
+ shimmerOrientation
+ }
val shimmerBackgroundColor = if (content != null) {
Color.Transparent
} else {
@@ -104,27 +121,56 @@ internal fun InternalShimmer(
val shimmerKnockoutEffectColor = tokens.knockoutEffectColor(shimmerInfo)
val cornerRadius =
dpToPx(cornerRadius)
- val shimmerDelay = tokens.delay(shimmerInfo)
val infiniteTransition = rememberInfiniteTransition()
- val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
- val initialValue = if (isLtr) 0f else screenWidth
+ val isLtr = if (orientation in listOf(
+ ShimmerOrientation.LEFT_TO_RIGHT,
+ ShimmerOrientation.TOPLEFT_TO_BOTTOMRIGHT
+ )
+ ) (LocalLayoutDirection.current == LayoutDirection.Ltr) else (LocalLayoutDirection.current == LayoutDirection.Rtl)
+
+ val initialValue = if (isLtr) 0f else diagonal
val targetValue = if (isLtr) diagonal else 0f
- val shimmerEffect by infiniteTransition.animateFloat(
- initialValue,
- targetValue,
- infiniteRepeatable(
- animation = tween(
- durationMillis = shimmerDelay,
- easing = LinearEasing
+ val shimmerEffect by if (isShimmering) {
+ infiniteTransition.animateFloat(
+ initialValue,
+ targetValue,
+ infiniteRepeatable(
+ animation = tween(
+ durationMillis = shimmerDelayValue,
+ easing = LinearEasing
+ ),
+ repeatMode = RepeatMode.Restart
+ )
)
- )
- )
+ }
+ else {
+ remember { mutableFloatStateOf(0f) }
+ }
+
+ val startOffset: Offset = when (orientation) {
+ ShimmerOrientation.LEFT_TO_RIGHT -> Offset.Zero
+ ShimmerOrientation.RIGHT_TO_LEFT -> Offset.Zero
+ ShimmerOrientation.TOPLEFT_TO_BOTTOMRIGHT -> Offset.Zero
+ ShimmerOrientation.BOTTOMRIGHT_TO_TOPLEFT -> Offset.Zero
+ else -> Offset.Zero
+ }
+ val endOffset: Offset = if (isShimmering) {
+ when (orientation) {
+ ShimmerOrientation.LEFT_TO_RIGHT -> Offset(shimmerEffect.absoluteValue, 0F)
+ ShimmerOrientation.RIGHT_TO_LEFT -> Offset(shimmerEffect.absoluteValue, 0F)
+ ShimmerOrientation.TOPLEFT_TO_BOTTOMRIGHT -> Offset(shimmerEffect.absoluteValue, shimmerEffect.absoluteValue)
+ ShimmerOrientation.BOTTOMRIGHT_TO_TOPLEFT -> Offset(shimmerEffect.absoluteValue, shimmerEffect.absoluteValue)
+ else -> Offset(shimmerEffect.absoluteValue, shimmerEffect.absoluteValue)
+ }
+ } else {
+ Offset.Zero
+ }
val gradientColor = Brush.linearGradient(
0f to shimmerBackgroundColor,
0.5f to shimmerKnockoutEffectColor,
1.0f to shimmerBackgroundColor,
- start = Offset.Zero,
- end = Offset(shimmerEffect.absoluteValue, shimmerEffect.absoluteValue)
+ start = startOffset,
+ end = endOffset
)
if (content != null) {
Box(
@@ -133,12 +179,14 @@ internal fun InternalShimmer(
.height(IntrinsicSize.Max)
) {
content()
- Spacer(
- modifier = Modifier
- .fillMaxSize()
- .clip(RoundedCornerShape(cornerRadius))
- .background(gradientColor)
- )
+ if(isShimmering) {
+ Spacer(
+ modifier = Modifier
+ .fillMaxSize()
+ .clip(RoundedCornerShape(cornerRadius))
+ .background(gradientColor)
+ )
+ }
}
} else {
Spacer(
diff --git a/fluentui_tablayout/build.gradle b/fluentui_tablayout/build.gradle
index f808986ad..b62186ccb 100644
--- a/fluentui_tablayout/build.gradle
+++ b/fluentui_tablayout/build.gradle
@@ -33,6 +33,9 @@ android {
composeOptions {
kotlinCompilerExtensionVersion composeCompilerVersion
}
+ lintOptions {
+ abortOnError false
+ }
}
dependencies {
diff --git a/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/navigation/TabBar.kt b/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/navigation/TabBar.kt
index 5d7fa7b84..c3fe509ba 100644
--- a/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/navigation/TabBar.kt
+++ b/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/navigation/TabBar.kt
@@ -5,8 +5,9 @@ import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.unit.dp
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import com.microsoft.fluentui.theme.FluentTheme
import com.microsoft.fluentui.theme.token.ControlTokens
import com.microsoft.fluentui.theme.token.FluentStyle
@@ -15,7 +16,7 @@ import com.microsoft.fluentui.theme.token.controlTokens.TabBarTokens
import com.microsoft.fluentui.theme.token.controlTokens.TabItemTokens
import com.microsoft.fluentui.theme.token.controlTokens.TabTextAlignment
import com.microsoft.fluentui.tokenized.tabItem.TabItem
-
+import com.microsoft.fluentui.tablayout.R
data class TabData(
var title: String,
@@ -23,7 +24,8 @@ data class TabData(
var selectedIcon: ImageVector = icon,
var selected: Boolean = false,
var onClick: () -> Unit,
- var badge: @Composable (() -> Unit)? = null
+ var badge: @Composable (() -> Unit)? = null,
+ var accessibilityDescription: String? = null, //Custom announcement for Talkback
)
/**
@@ -52,6 +54,7 @@ fun TabBar(
FluentTheme.themeID //Adding This only for recomposition in case of Token Updates. Unused otherwise.
val token = tabBarTokens
?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.TabBarControlType] as TabBarTokens
+ val resources = LocalContext.current.resources
Column(modifier.fillMaxWidth()) {
Box(
@@ -65,9 +68,16 @@ fun TabBar(
) {
tabDataList.forEachIndexed { index, tabData ->
tabData.selected = index == selectedIndex
+ var accessibilityDescriptionValue = if(tabData.accessibilityDescription != null) { tabData.accessibilityDescription }
+ else{ tabData.title + if(tabData.selected) resources.getString(R.string.tab_active).prependIndent(": ") else resources.getString(R.string.tab_inactive).prependIndent(": ") }
TabItem(
title = tabData.title,
modifier = Modifier
+ .semantics {
+ if (accessibilityDescriptionValue != null) {
+ contentDescription = accessibilityDescriptionValue
+ }
+ }
.fillMaxWidth()
.weight(1F),
icon = if (tabData.selected) tabData.selectedIcon else tabData.icon,
diff --git a/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/navigation/ViewPager.kt b/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/navigation/ViewPager.kt
new file mode 100644
index 000000000..b44c226f3
--- /dev/null
+++ b/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/navigation/ViewPager.kt
@@ -0,0 +1,55 @@
+package com.microsoft.fluentui.tokenized.navigation
+
+import android.graphics.Paint.Align
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.PageSize
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import com.microsoft.fluentui.theme.FluentTheme
+import com.microsoft.fluentui.theme.token.ControlTokens
+import com.microsoft.fluentui.theme.token.controlTokens.ViewPagerInfo
+import com.microsoft.fluentui.theme.token.controlTokens.ViewPagerTokens
+
+/**
+ * API to create a ViewPager.
+ *
+ * @param pagerState PagerState to manage the state of ViewPager
+ * @param pageContent Content to be displayed in ViewPager
+ * @param modifier Optional modifier for ViewPager
+ * @param pageSize Size of the page. Default: [PageSize.Fill]
+ * @param userScrollEnabled Boolean for enabling/disabling user scroll. Default: [false]
+ * @param verticalAlignment Alignment of content in ViewPager. Default: [Alignment.CenterVertically]
+ * @param viewPagerTokens Tokens to customize appearance of ViewPager. Default: [null]
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ViewPager(
+ pagerState: PagerState,
+ pageContent: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ pageSize: PageSize = PageSize.Fill,
+ userScrollEnabled: Boolean = false,
+ verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
+ viewPagerTokens: ViewPagerTokens? = null
+) {
+ val token =
+ viewPagerTokens
+ ?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.ViewPagerControlType] as ViewPagerTokens
+
+ val viewPagerInfo = ViewPagerInfo()
+ // HorizontalPager is a horizontally scrolling pager using the provided pagerState
+ HorizontalPager(
+ state = pagerState,
+ modifier = modifier,
+ contentPadding = token.contentPadding(viewPagerInfo),
+ pageSpacing = token.pageSpacing(viewPagerInfo),
+ pageSize = pageSize,
+ userScrollEnabled = userScrollEnabled,
+ verticalAlignment = verticalAlignment
+ ) {
+ pageContent()
+ }
+}
diff --git a/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/segmentedcontrols/Pill.kt b/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/segmentedcontrols/Pill.kt
index 004bc4d57..7844bc3f5 100644
--- a/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/segmentedcontrols/Pill.kt
+++ b/fluentui_tablayout/src/main/java/com/microsoft/fluentui/tokenized/segmentedcontrols/Pill.kt
@@ -126,6 +126,11 @@ fun PillButton(
selected = pillMetaData.selected,
interactionSource = interactionSource
)
+
+ val borderColor = token.borderColor(pillButtonInfo = pillButtonInfo)
+
+ val borderWidth = token.borderWidth(pillButtonInfo = pillButtonInfo)
+
val iconColor =
token.iconColor(pillButtonInfo = pillButtonInfo).getColorByState(
enabled = pillMetaData.enabled,
@@ -174,6 +179,7 @@ fun PillButton(
.defaultMinSize(minHeight = token.minHeight(pillButtonInfo))
.clip(shape)
.background(backgroundColor, shape)
+ .border(width = borderWidth, color = borderColor, shape = shape)
.then(clickAndSemanticsModifier)
.then(if (interactionSource.collectIsFocusedAsState().value || interactionSource.collectIsHoveredAsState().value) focusedBorderModifier else Modifier)
.padding(vertical = token.verticalPadding(pillButtonInfo))
diff --git a/fluentui_tablayout/src/main/res/values/strings.xml b/fluentui_tablayout/src/main/res/values/strings.xml
index f3d06bb1f..2de3c71d6 100644
--- a/fluentui_tablayout/src/main/res/values/strings.xml
+++ b/fluentui_tablayout/src/main/res/values/strings.xml
@@ -4,5 +4,11 @@
\u0020 tab %1$d of %2$d
- Item %d in list of %d
+ Item %1$d in list of %2$d
+
+
+ Active
+
+
+ Inactive
\ No newline at end of file
diff --git a/fluentui_topappbars/build.gradle b/fluentui_topappbars/build.gradle
index a2dc541a8..e7f8a6514 100644
--- a/fluentui_topappbars/build.gradle
+++ b/fluentui_topappbars/build.gradle
@@ -39,6 +39,9 @@ android {
kotlinOptions {
jvmTarget = '1.8'
}
+ lintOptions {
+ abortOnError false
+ }
}
dependencies {
diff --git a/fluentui_topappbars/src/main/java/com/microsoft/fluentui/search/Searchbar.kt b/fluentui_topappbars/src/main/java/com/microsoft/fluentui/search/Searchbar.kt
index 423a5bb37..2fa7e0461 100644
--- a/fluentui_topappbars/src/main/java/com/microsoft/fluentui/search/Searchbar.kt
+++ b/fluentui_topappbars/src/main/java/com/microsoft/fluentui/search/Searchbar.kt
@@ -14,7 +14,6 @@ import android.util.AttributeSet
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.InputMethodManager
-import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
@@ -22,10 +21,8 @@ import android.widget.RelativeLayout
import com.microsoft.fluentui.topappbars.R
import com.microsoft.fluentui.appbarlayout.AppBarLayout
import com.microsoft.fluentui.theming.FluentUIContextThemeWrapper
-import com.microsoft.fluentui.util.DuoSupportUtils
import com.microsoft.fluentui.util.inputMethodManager
import com.microsoft.fluentui.util.isVisible
-import com.microsoft.fluentui.util.activity
import com.microsoft.fluentui.util.toggleKeyboardVisibility
import com.microsoft.fluentui.view.TemplateView
import com.microsoft.fluentui.progress.ProgressBar
@@ -156,8 +153,6 @@ open class Searchbar : TemplateView, SearchView.OnQueryTextListener {
private var searchView: SearchView? = null
private var searchCloseButton: ImageButton? = null
private var searchProgress: ProgressBar? = null
- private var singleScreenDisplayPixels = 0
- private var screenPos = IntArray(2)
override fun onTemplateLoaded() {
super.onTemplateLoaded()
@@ -169,9 +164,6 @@ open class Searchbar : TemplateView, SearchView.OnQueryTextListener {
searchView = findViewInTemplateById(R.id.search_view)
searchCloseButton = findViewInTemplateById(R.id.search_close)
searchProgress = findViewInTemplateById(R.id.search_progress)
- context.activity?.let {
- singleScreenDisplayPixels = DuoSupportUtils.getSingleScreenWidthPixels(it)
- }
// Hide the default search view close button from TalkBack and get rid of the space it takes up.
val closeButton = searchView?.findViewById(R.id.search_close_btn)
@@ -183,21 +175,6 @@ open class Searchbar : TemplateView, SearchView.OnQueryTextListener {
setUnfocusedState()
}
- override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
- var widthMeasureSpec = widthMeasureSpec
- val viewWidth = MeasureSpec.getSize(widthMeasureSpec)
- this.getLocationOnScreen(screenPos)
-
- // Adjust x coordinate for second screen on Duo
- if (screenPos[0] > singleScreenDisplayPixels)
- screenPos[0] -= singleScreenDisplayPixels + DuoSupportUtils.DUO_HINGE_WIDTH
-
- // Adjust for hinge
- if (screenPos[0] + viewWidth > singleScreenDisplayPixels)
- widthMeasureSpec = MeasureSpec.makeMeasureSpec(singleScreenDisplayPixels - screenPos[0], MeasureSpec.EXACTLY)
- super.onMeasure(widthMeasureSpec, heightMeasureSpec)
- }
-
private fun updateViews() {
searchView?.queryHint = queryHint
searchView?.setSearchableInfo(searchableInfo)
diff --git a/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/AppBar.kt b/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/AppBar.kt
index 679378edf..6d5039b1f 100644
--- a/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/AppBar.kt
+++ b/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/AppBar.kt
@@ -2,13 +2,9 @@ package com.microsoft.fluentui.tokenized
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicText
-import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -20,21 +16,20 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.microsoft.fluentui.core.R
import com.microsoft.fluentui.icons.ListItemIcons
-import com.microsoft.fluentui.icons.SearchBarIcons
-import com.microsoft.fluentui.icons.appbaricons.AppBarIcons
-import com.microsoft.fluentui.icons.appbaricons.appbaricons.Arrowback
import com.microsoft.fluentui.icons.listitemicons.Chevron
import com.microsoft.fluentui.theme.FluentTheme
import com.microsoft.fluentui.theme.token.*
import com.microsoft.fluentui.theme.token.controlTokens.AppBarInfo
import com.microsoft.fluentui.theme.token.controlTokens.AppBarSize
import com.microsoft.fluentui.theme.token.controlTokens.AppBarTokens
+import androidx.compose.runtime.*
+import androidx.compose.ui.unit.*
+import com.microsoft.fluentui.util.clickableWithTooltip
/**
* An app bar appears at the top of an app screen, below the status bar,
@@ -54,7 +49,6 @@ import com.microsoft.fluentui.theme.token.controlTokens.AppBarTokens
* @param subTitle Subtitle to be displayed. Default: [null]
* @param logo Composable to be placed at left of Title. Guideline is to not increase a size of 32x32. Default: [null]
* @param searchMode Boolean to enable/disable searchMode. Default: [false]
- * @param navigationIcon Navigate Back Icon to be placed at extreme left. Default: [SearchBarIcons.Arrowback]
* @param postTitleIcon Icon to be placed after title making the title clickable. Default: Empty [FluentIcon]
* @param preSubtitleIcon Icon to be placed before subtitle. Default: Empty [FluentIcon]
* @param postSubtitleIcon Icon to be placed after subtitle. Default: [ListItemIcons.Chevron]
@@ -64,7 +58,10 @@ import com.microsoft.fluentui.theme.token.controlTokens.AppBarTokens
* @param bottomBorder Boolean to place a bottom border on AppBar. Applies only when searchBar and bottomBar are empty. Default: [true]
* @param appTitleDelta Ratio of opening of appTitle. Used for Shychrome and other animations. Default: [1.0F]
* @param accessoryDelta Ratio of opening of accessory View. Used for Shychrome and other animations. Default: [1.0F]
+ * @param centerAlignAppBar boolean indicating if the app bar should be center aligned. Default: [false]
+ * @param navigationIcon Navigate Back Icon to be placed at extreme left. Default: [null]
* @param appBarTokens Optional Tokens for App Bar to customize it. Default: [null]
+ * @param secondaryPostTitleIcon Secondary icon to be placed after title. Default: Empty [FluentIcon]
*/
// TAGS FOR TESTING
@@ -72,7 +69,7 @@ const val APP_BAR = "Fluent App bar"
const val APP_BAR_SUBTITLE = "Fluent App bar Subtitle"
const val APP_BAR_BOTTOM_BAR = "Fluent App bar Bottom bar"
const val APP_BAR_SEARCH_BAR = "Fluent App bar Search bar"
-@OptIn(ExperimentalTextApi::class)
+
@Composable
fun AppBar(
title: String,
@@ -82,7 +79,6 @@ fun AppBar(
subTitle: String? = null,
logo: @Composable (() -> Unit)? = null,
searchMode: Boolean = false,
- navigationIcon: FluentIcon = FluentIcon(AppBarIcons.Arrowback, flipOnRtl = true),
postTitleIcon: FluentIcon = FluentIcon(),
preSubtitleIcon: FluentIcon = FluentIcon(),
postSubtitleIcon: FluentIcon = FluentIcon(
@@ -95,16 +91,18 @@ fun AppBar(
bottomBorder: Boolean = true,
appTitleDelta: Float = 1.0F,
accessoryDelta: Float = 1.0F,
- appBarTokens: AppBarTokens? = null
+ centerAlignAppBar: Boolean = false,
+ navigationIcon: FluentIcon? = null,
+ appBarTokens: AppBarTokens? = null,
+ secondaryPostTitleIcon: FluentIcon = FluentIcon(),
) {
val themeID =
FluentTheme.themeID //Adding This only for recomposition in case of Token Updates. Unused otherwise.
val token = appBarTokens
?: FluentTheme.controlTokens.tokens[ControlTokens.ControlType.AppBarControlType] as AppBarTokens
-
-
val appBarInfo = AppBarInfo(style, appBarSize)
+ val tooltipControls = token.tooltipVisibilityControls(appBarInfo)
Box(
modifier = modifier
.fillMaxWidth()
@@ -140,61 +138,69 @@ fun AppBar(
.fillMaxWidth()
.scale(scaleX = 1.0F, scaleY = appTitleDelta)
.alpha(if (appTitleDelta != 1.0F) appTitleDelta / 3 else 1.0F),
- horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
- if (appBarSize != AppBarSize.Large && navigationIcon.isIconAvailable()) {
+ if (navigationIcon !== null && navigationIcon.isIconAvailable()) {
Icon(
navigationIcon,
- modifier =
- Modifier.then(
- if(navigationIcon.onClick != null)
- Modifier.clickable(
- interactionSource = remember { MutableInteractionSource() },
- indication = rememberRipple(color = token.navigationIconRippleColor()),
- enabled = true,
- onClick = navigationIcon.onClick ?: {}
+ modifier = Modifier.clickableWithTooltip(
+ tooltipText = navigationIcon.contentDescription ?: "",
+ tooltipEnabled = tooltipControls.enableNavigationIconTooltip,
+ backgroundColor = token.tooltipBackgroundBrush(appBarInfo),
+ textStyle = token.tooltipTextStyle(appBarInfo),
+ cornerRadius = token.tooltipCornerRadius(appBarInfo),
+ clickRippleColor = token.navigationIconRippleColor(),
+ onClick = navigationIcon.onClick,
+ onLongClick = navigationIcon.onLongClick,
+ offset = token.tooltipOffset(appBarInfo),
+ timeout = token.tooltipTimeout(appBarInfo),
)
- else Modifier
- )
.padding(token.navigationIconPadding(appBarInfo))
.size(token.leftIconSize(appBarInfo)),
tint = token.navigationIconColor(appBarInfo)
)
}
- if (appBarSize != AppBarSize.Medium) {
- Box(
- modifier = Modifier
- .then(
- if (appBarSize == AppBarSize.Large)
- Modifier.padding(start = 16.dp)
- else
- Modifier
- )
- ) {
- logo?.invoke()
- }
- }
+ logo?.invoke()
val titleTextStyle = token.titleTypography(appBarInfo)
val subtitleTextStyle = token.subtitleTypography(appBarInfo)
+ val titleAlignment: Alignment.Horizontal =
+ if (centerAlignAppBar) Alignment.CenterHorizontally else Alignment.Start
- if (appBarSize != AppBarSize.Large && !subTitle.isNullOrBlank()) {
+ if (appBarSize != AppBarSize.Large) {
Column(
modifier = Modifier
.weight(1F)
.padding(token.textPadding(appBarInfo))
- .testTag(APP_BAR_SUBTITLE)
+ .testTag(APP_BAR_SUBTITLE),
+ horizontalAlignment = titleAlignment
) {
+ // title
Row(
- modifier = Modifier
- .then(
- if (postTitleIcon.onClick != null && appBarSize == AppBarSize.Small)
- Modifier.clickable(onClick = postTitleIcon.onClick!!)
- else
- Modifier
- ), verticalAlignment = Alignment.CenterVertically
+ modifier = Modifier.clickableWithTooltip(
+ tooltipText = title,
+ tooltipEnabled = tooltipControls.enableTitleTooltip,
+ backgroundColor = token.tooltipBackgroundBrush(
+ appBarInfo
+ ),
+ textStyle = token.tooltipTextStyle(appBarInfo),
+ cornerRadius = token.tooltipCornerRadius(appBarInfo),
+ clickRippleColor = token.tooltipRippleColor(appBarInfo),
+ onClick = {
+ if (appBarSize == AppBarSize.Small) {
+ postTitleIcon.onClick?.invoke()
+ }
+ },
+ onLongClick = {
+ if (appBarSize == AppBarSize.Small) {
+ postTitleIcon.onLongClick?.invoke()
+ }
+ },
+ offset = token.tooltipOffset(appBarInfo),
+ timeout = token.tooltipTimeout(appBarInfo)
+ ),
+ verticalAlignment = Alignment.CenterVertically
) {
BasicText(
text = title,
@@ -204,7 +210,8 @@ fun AppBar(
)
),
maxLines = 1,
- overflow = TextOverflow.Ellipsis
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f, fill = false)
)
if (postTitleIcon.isIconAvailable() && appBarSize == AppBarSize.Small)
Icon(
@@ -214,68 +221,116 @@ fun AppBar(
.size(token.titleIconSize(appBarInfo)),
tint = token.titleIconColor(appBarInfo),
)
- }
- Row(
- modifier = Modifier
- .then(
- if (postSubtitleIcon.onClick != null)
- Modifier.clickable(onClick = postSubtitleIcon.onClick!!)
- else
- Modifier
- ), verticalAlignment = Alignment.CenterVertically
- ) {
- if (preSubtitleIcon.isIconAvailable())
+
+ if (secondaryPostTitleIcon.isIconAvailable() && appBarSize == AppBarSize.Small)
Icon(
- preSubtitleIcon,
+ secondaryPostTitleIcon.value(),
+ secondaryPostTitleIcon.contentDescription,
modifier = Modifier
- .size(
- token.subtitleIconSize(
- appBarInfo
- )
- ),
- tint = token.subtitleIconColor(appBarInfo)
+ .size(token.titleIconSize(appBarInfo)),
+ tint = token.titleIconColor(appBarInfo),
)
- BasicText(
- subTitle,
- style = subtitleTextStyle.merge(
- TextStyle(
- color = token.subtitleTextColor(
+ }
+ // subtitle
+ if (!subTitle.isNullOrBlank()) {
+ Row(
+ modifier = Modifier.clickableWithTooltip(
+ tooltipText = subTitle,
+ tooltipEnabled = tooltipControls.enableSubtitleTooltip,
+ backgroundColor = token.tooltipBackgroundBrush(
appBarInfo
- )
+ ),
+ textStyle = token.tooltipTextStyle(appBarInfo),
+ cornerRadius = token.tooltipCornerRadius(appBarInfo),
+ clickRippleColor = token.tooltipRippleColor(
+ appBarInfo
+ ),
+ onClick = {
+ if (appBarSize == AppBarSize.Small) {
+ preSubtitleIcon.onClick?.invoke()
+ }
+ },
+ onLongClick = {
+ if (appBarSize == AppBarSize.Small) {
+ postSubtitleIcon.onLongClick?.invoke()
+ }
+ },
+ offset = token.tooltipOffset(appBarInfo),
+ timeout = token.tooltipTimeout(appBarInfo)
+ ),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (preSubtitleIcon.isIconAvailable())
+ Icon(
+ preSubtitleIcon,
+ modifier = Modifier
+ .size(
+ token.subtitleIconSize(
+ appBarInfo
+ )
+ ),
+ tint = token.subtitleIconColor(appBarInfo)
)
- ),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- if (postSubtitleIcon.isIconAvailable())
- Icon(
- postSubtitleIcon.value(),
- contentDescription = postSubtitleIcon.contentDescription,
- modifier = Modifier
- .size(
- token.subtitleIconSize(
+ BasicText(
+ subTitle,
+ style = subtitleTextStyle.merge(
+ TextStyle(
+ color = token.subtitleTextColor(
appBarInfo
)
- ),
- tint = token.subtitleIconColor(appBarInfo)
+ )
+ ),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
)
+ if (postSubtitleIcon.isIconAvailable())
+ Icon(
+ postSubtitleIcon.value(),
+ contentDescription = postSubtitleIcon.contentDescription,
+ modifier = Modifier
+ .size(
+ token.subtitleIconSize(
+ appBarInfo
+ )
+ ),
+ tint = token.subtitleIconColor(appBarInfo)
+ )
+ }
}
}
} else {
- BasicText(
- text = title,
- modifier = Modifier
+ Column(
+ modifier = Modifier.clickableWithTooltip(
+ tooltipText = title,
+ tooltipEnabled = tooltipControls.enableTitleTooltip,
+ backgroundColor = token.tooltipBackgroundBrush(appBarInfo),
+ cornerRadius = token.tooltipCornerRadius(appBarInfo),
+ textStyle = token.tooltipTextStyle(appBarInfo),
+ clickRippleColor = token.tooltipRippleColor(appBarInfo),
+ showRippleOnClick = false,
+ onClick = {},
+ onLongClick = {},
+ offset = token.tooltipOffset(appBarInfo),
+ timeout = token.tooltipTimeout(appBarInfo)
+ )
.padding(token.textPadding(appBarInfo))
.weight(1F)
.semantics { heading() },
- style = titleTextStyle.merge(
- TextStyle(
- color = token.titleTextColor(appBarInfo)
- )
- ),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
+ horizontalAlignment = titleAlignment
+ ) {
+
+ BasicText(
+ text = title,
+ style = titleTextStyle.merge(
+ TextStyle(
+ color = token.titleTextColor(appBarInfo)
+ )
+ ),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ }
}
if (rightAccessoryView != null) {
diff --git a/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/SearchBar.kt b/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/SearchBar.kt
index 286d30692..37353e033 100644
--- a/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/SearchBar.kt
+++ b/fluentui_topappbars/src/main/java/com/microsoft/fluentui/tokenized/SearchBar.kt
@@ -1,8 +1,8 @@
package com.microsoft.fluentui.tokenized
import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
@@ -15,10 +15,10 @@ import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
-import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
@@ -73,8 +73,6 @@ import com.microsoft.fluentui.topappbars.R
* @param rightAccessoryIcon [FluentIcon] Object which is displayed on the right side of microphone. Default: [null]
* @param searchBarTokens Tokens which help in customizing appearance of search bar. Default: [null]
*/
-// AnimatedContent Backspace Key
-@OptIn(ExperimentalComposeUiApi::class, ExperimentalAnimationApi::class)
@Composable
fun SearchBar(
onValueChange: (String, Person?) -> Unit,
@@ -90,6 +88,7 @@ fun SearchBar(
personaChipOnClick: (() -> Unit)? = null,
microphoneCallback: (() -> Unit)? = null,
navigationIconCallback: (() -> Unit)? = null,
+ leftAccessoryIcon: ImageVector? = SearchBarIcons.Search,
rightAccessoryIcon: FluentIcon? = null,
searchBarTokens: SearchBarTokens? = null
) {
@@ -106,8 +105,23 @@ fun SearchBar(
var personaChipSelected by rememberSaveable { mutableStateOf(false) }
var selectedPerson: Person? = selectedPerson
+ val borderWidth = token.borderWidth(searchBarInfo)
+ val elevation = token.elevation(searchBarInfo)
+ val height = token.height(searchBarInfo)
val scope = rememberCoroutineScope()
+ val borderModifier = if (borderWidth > 0.dp) {
+ Modifier.border(
+ width = borderWidth,
+ color = token.borderColor(searchBarInfo),
+ shape = RoundedCornerShape(token.cornerRadius(searchBarInfo))
+ )
+ } else Modifier
+ val shadowModifier = if (elevation > 0.dp) Modifier.shadow(
+ elevation = token.elevation(searchBarInfo),
+ shape = RoundedCornerShape(token.cornerRadius(searchBarInfo)),
+ spotColor = token.shadowColor(searchBarInfo)
+ ) else Modifier
Row(
modifier = modifier
@@ -116,12 +130,14 @@ fun SearchBar(
) {
Row(
Modifier
- .requiredHeightIn(min = token.height(searchBarInfo))
+ .requiredHeightIn(min = height)
+ .then(borderModifier)
+ .then(shadowModifier)
.fillMaxWidth()
- .clip(RoundedCornerShape(8.dp))
+ .clip(RoundedCornerShape(token.cornerRadius(searchBarInfo)))
.background(
token.inputBackgroundBrush(searchBarInfo),
- RoundedCornerShape(8.dp)
+ RoundedCornerShape(token.cornerRadius(searchBarInfo))
),
verticalAlignment = Alignment.CenterVertically
) {
@@ -152,11 +168,12 @@ fun SearchBar(
if (LocalLayoutDirection.current == LayoutDirection.Rtl)
mirrorImage = true
}
+
false -> {
onClick = {
focusRequester.requestFocus()
}
- icon = SearchBarIcons.Search
+ icon = leftAccessoryIcon ?: SearchBarIcons.Search
contentDescription =
LocalContext.current.resources.getString(R.string.fluentui_search)
mirrorImage = false
@@ -306,6 +323,7 @@ fun SearchBar(
onClick = microphoneCallback
)
}
+
false ->
Box(
modifier = Modifier
diff --git a/fluentui_transients/build.gradle b/fluentui_transients/build.gradle
index a5b071dd5..065d18d43 100644
--- a/fluentui_transients/build.gradle
+++ b/fluentui_transients/build.gradle
@@ -27,6 +27,9 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
+ lintOptions {
+ abortOnError false
+ }
}
dependencies {
diff --git a/fluentui_transients/src/main/java/com/microsoft/fluentui/snackbar/Snackbar.kt b/fluentui_transients/src/main/java/com/microsoft/fluentui/snackbar/Snackbar.kt
index 136752391..8163cf643 100644
--- a/fluentui_transients/src/main/java/com/microsoft/fluentui/snackbar/Snackbar.kt
+++ b/fluentui_transients/src/main/java/com/microsoft/fluentui/snackbar/Snackbar.kt
@@ -11,7 +11,6 @@ import com.google.android.material.snackbar.BaseTransientBottomBar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.appcompat.widget.AppCompatButton
-import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -22,9 +21,7 @@ import com.microsoft.fluentui.transients.R
import com.microsoft.fluentui.transients.R.id.*
import com.microsoft.fluentui.theming.FluentUIContextThemeWrapper
import com.microsoft.fluentui.transients.databinding.ViewSnackbarBinding
-import com.microsoft.fluentui.util.DuoSupportUtils
import com.microsoft.fluentui.util.ThemeUtil
-import com.microsoft.fluentui.util.activity
/**
* Snackbars provide lightweight feedback about an operation by showing a brief message at the bottom of the screen.
@@ -137,55 +134,6 @@ class Snackbar : BaseTransientBottomBar {
actionButtonView = binding.snackbarAction
updateBackground()
- // Set the margin on the FrameLayout (SnackbarLayout) instead of the content because the content's bottom margin is buggy in some APIs.
- if (content.parent is FrameLayout) {
- context.activity?.let {
- if(DuoSupportUtils.isWindowDoublePortrait(it)) {
- val singleScreenDisplayPixels = DuoSupportUtils.getSingleScreenWidthPixels(it)
- val snackbarLP = getView().layoutParams
- snackbarLP.width = singleScreenDisplayPixels
- getView().layoutParams = snackbarLP
- alignLeft(parent)
- }
- }
- }
- }
-
- /**
- * This is adapted from android.support.design.widget.Snackbar
- * It ensures we can use Snackbars in complex ViewGroups like RecyclerView.
- */
- private fun alignLeft(view: View) {
- var currentView: View? = view
- var fallbackParent: ViewGroup? = null
-
- do {
- if (currentView is CoordinatorLayout) {
- // We've found a CoordinatorLayout, use it
- val params = getView().layoutParams as CoordinatorLayout.LayoutParams
- params.gravity = Gravity.BOTTOM
- getView().layoutParams = params
- return
- }
-
- if (currentView is FrameLayout)
- if (currentView.id == android.R.id.content) {
- // If we've hit the decor content view, then we didn't find a CoL in the
- // hierarchy, so use it.
- val params = getView().layoutParams as FrameLayout.LayoutParams
- params.gravity = Gravity.BOTTOM
- view.layoutParams = params
- return
- } else
- // It's not the content view but we'll use it as our fallback
- fallbackParent = currentView
-
- // Else, we will loop and crawl up the view hierarchy and try to find a parent
- currentView = currentView?.parent as? View
- } while (currentView != null)
-
- // If we reach here then we didn't find a CoL or a suitable content view so we'll fallback
- return
}
/**
@@ -216,7 +164,8 @@ class Snackbar : BaseTransientBottomBar {
actionButtonView.visibility = View.VISIBLE
actionButtonView.setOnClickListener { view ->
listener.onClick(view)
- dismiss()
+ // dismiss the Snackbar
+ dispatchDismiss(BaseCallback.DISMISS_EVENT_ACTION)
}
updateStyle()
diff --git a/fluentui_transients/src/main/java/com/microsoft/fluentui/tooltip/Tooltip.kt b/fluentui_transients/src/main/java/com/microsoft/fluentui/tooltip/Tooltip.kt
index 468b70cb1..639ffb5ed 100644
--- a/fluentui_transients/src/main/java/com/microsoft/fluentui/tooltip/Tooltip.kt
+++ b/fluentui_transients/src/main/java/com/microsoft/fluentui/tooltip/Tooltip.kt
@@ -162,7 +162,6 @@ class Tooltip {
initTooltipArrow(anchorRect, anchor.layoutIsRtl, config.offsetX)
checkEdgeCase(anchorRect)
- hingeSupport(anchorRect, config.touchDismissLocation)
if (requireReinit) initTooltipArrow(anchorRect, anchor.layoutIsRtl, config.offsetX)
if (requireReadjustment) readjustTooltip(anchorRect, anchor.layoutIsRtl, config)
@@ -226,12 +225,6 @@ class Tooltip {
private fun setPositionX(anchorCenter: Int, offsetX: Int) {
positionX = anchorCenter - contentWidth / 2 + offsetX
- // Duo Second Screen Support
- val secondScreen = anchorCenter > displayWidth && context.activity?.let {
- DuoSupportUtils.isDeviceSurfaceDuo(it)
- } ?: false
- if (secondScreen) positionX -= displayWidth + DuoSupportUtils.DUO_HINGE_WIDTH
-
// Navigation Bar in Nougat+ can appear on the left on phones at 270 rotation and adds
// its height to the left of the display creating an offset that needs to be corrected to get
// accurate horizontal position.
@@ -249,18 +242,11 @@ class Tooltip {
private fun setPositionY(anchor: Rect, offsetY: Int, dismissLocation: TouchDismissLocation) {
positionY = anchor.bottom
- // Duo Second Screen Support
- val secondScreen = anchor.bottom > displayHeight && context.activity?.let {
- DuoSupportUtils.isDeviceSurfaceDuo(it)
- } ?: false
- if (secondScreen) positionY -= displayHeight + DuoSupportUtils.DUO_HINGE_WIDTH
-
isAboveAnchor = context.activity?.let {
positionY + contentHeight + margin > displayHeight
} ?: false
if (isAboveAnchor) {
positionY = anchor.top - contentHeight - offsetY
- if (secondScreen) positionY -= displayHeight + DuoSupportUtils.DUO_HINGE_WIDTH
}
}
@@ -302,10 +288,7 @@ class Tooltip {
val layoutParams = toolTipArrow.layoutParams as LinearLayout.LayoutParams
val cornerRadius = context.resources.getDimensionPixelSize(R.dimen.fluentui_tooltip_radius)
if (!isSideAnchor) { // Normal Top/Bottom arrow
- val anchorCenterX = if (anchorRect.centerX() > displayWidth && context.activity?.let {
- DuoSupportUtils.isDeviceSurfaceDuo(it)
- } == true) anchorRect.centerX() - displayWidth - DuoSupportUtils.DUO_HINGE_WIDTH
- else anchorRect.centerX()
+ val anchorCenterX = anchorRect.centerX()
val offset = if (isRTL)
positionX + contentWidth - anchorCenterX - tooltipArrowWidth
@@ -316,10 +299,6 @@ class Tooltip {
} else {// Edge Case Left/Right arrow
layoutParams.gravity = Gravity.TOP
var topMargin = anchorRect.centerY() - positionY - tooltipArrowWidth
- val secondScreen = anchorRect.top > displayHeight && context.activity?.let {
- DuoSupportUtils.isDeviceSurfaceDuo(it)
- } ?: false
- topMargin -= if (secondScreen) displayHeight else 0
if (positionY + contentHeight >= displayHeight) topMargin -= cornerRadius
layoutParams.topMargin = topMargin
}
@@ -344,63 +323,19 @@ class Tooltip {
val leftEdge =
(startPosition - cornerRadius - margin - context.softNavBarOffsetX < 0) || (doesNotFitAboveOrBelow && anchorRect.left < rightSpace)
- // Duo Support
- val secondScreen = anchorRect.left > displayWidth && context.activity?.let {
- DuoSupportUtils.isDeviceSurfaceDuo(it)
- } ?: false
if (leftEdge) { // checks if the arrow is cut by the left edge of the screen and sets positionX to the left of the anchor with proper width.
positionX = anchorRect.right
- if (secondScreen) positionX -= displayWidth + DuoSupportUtils.DUO_HINGE_WIDTH
}
if (rightEdge) { // checks if the arrow is cut by the right edge of the screen and sets positionX to the left of the anchor with proper width.
isAboveAnchor = true // Enables right arrow
positionX = anchorRect.left - contentWidth - upArrowWidth / 2
- if (secondScreen) positionX -= displayWidth + DuoSupportUtils.DUO_HINGE_WIDTH
}
if (leftEdge || rightEdge)
requireReadjustment = true
}
- private fun hingeSupport(anchorRect: Rect, dismissLocation: TouchDismissLocation) {
- context.activity?.let {
- val upArrowWidth =
- context.resources.getDimensionPixelSize(R.dimen.fluentui_tooltip_arrow_width)
- val tooltipRect = Rect(
- positionX,
- positionY,
- positionX + contentWidth,
- positionY + contentHeight - upArrowWidth / 2
- )
- val anchorIntersects = DuoSupportUtils.intersectHinge(it, anchorRect)
- val tooltipIntersects = DuoSupportUtils.intersectHinge(it, tooltipRect)
-
- if (anchorIntersects || tooltipIntersects) {
- if (DuoSupportUtils.isWindowDoublePortrait(it)) {
- isAboveAnchor = false // Enables left arrow
- if (DuoSupportUtils.moreOnLeft(it, anchorRect)) {
- isAboveAnchor = true // Enables right arrow
- positionX = anchorRect.left - contentWidth - upArrowWidth / 2
- } else {
- positionX = anchorRect.right
- }
- requireReadjustment = true
- } else { // Device is in vertical orientation
- // Usually the tooltip will occur below the anchor, so the tooltip will intersect only in case its in top screen
- // In such case, we make tooltip on the top of the anchor.
- if (tooltipIntersects) {
- isAboveAnchor = true
- isSideAnchor = false
- positionY = anchorRect.top - contentHeight
- if (dismissLocation == TouchDismissLocation.INSIDE) positionY -= context.statusBarHeight
- requireReinit = true
- }
- }
- }
- }
- }
-
private fun readjustTooltip(anchorRect: Rect, isRTL: Boolean, config: Config) {
val upArrowWidth =
context.resources.getDimensionPixelSize(R.dimen.fluentui_tooltip_arrow_width)
@@ -427,10 +362,6 @@ class Tooltip {
// Otherwise sets positionY such that the content ends at the bottom of anchor
else anchorRect.bottom - contentHeight
- val secondScreen = anchorRect.top > displayHeight && context.activity?.let {
- DuoSupportUtils.isDeviceSurfaceDuo(it)
- } ?: false
- positionY -= if (secondScreen) displayHeight else 0
// Readjusts positionY if it crosses AppBar on the top
if (positionY < topBarHeight + context.statusBarHeight)
@@ -438,20 +369,6 @@ class Tooltip {
if (config.touchDismissLocation == TouchDismissLocation.INSIDE)
positionY -= context.statusBarHeight
- // Readjustment for Duo hinge
- val tooltipRect =
- Rect(positionX, positionY, positionX + contentWidth, positionY + contentHeight)
- context.activity?.let {
- if (DuoSupportUtils.intersectHinge(it, tooltipRect)) {
- positionY = if (DuoSupportUtils.moreOnTop(it, anchorRect)) {
- DuoSupportUtils.getHinge(it)!!.top - contentHeight - margin + cornerRadius
- } else {
- DuoSupportUtils.getHinge(it)!!.bottom + margin - cornerRadius
- }
- isAboveAnchor = tooltipRect.left < anchorRect.left
- }
- }
-
// Reinitialize tooltip with side arrow
initTooltipArrow(anchorRect, isRTL, config.offsetX)
if (config.touchDismissLocation == TouchDismissLocation.INSIDE)
diff --git a/publish.gradle b/publish.gradle
index f6c0363fa..68d7e625f 100644
--- a/publish.gradle
+++ b/publish.gradle
@@ -36,7 +36,7 @@ project.ext.publishingFunc = { artifactIdName ->
url rootProject.buildDir.path + '/artifacts'
}
}
- tasks.withType(PublishToMavenRepository) {
+ tasks.withType(PublishToMavenRepository){
onlyIf {
(repository == publishing.repositories.local && !(artifactExists("central", artifactIdName, android.defaultConfig.versionName))) || (repository == publishing.repositories.feed && !(artifactExists("feed", artifactIdName, android.defaultConfig.versionName)))
}