/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.constraintlayout.helper.widget;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.util.Log;
import android.util.Pair;
import androidx.constraintlayout.motion.widget.Debug;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.constraintlayout.widget.Guideline;
import androidx.constraintlayout.widget.R;
import androidx.constraintlayout.widget.VirtualLayout;
import androidx.core.view.ViewCompat;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* A helper class that helps arrange widgets in a grid form
*
* <h2>Grid</h2>
* <table summary="Grid attributes">
* <tr>
* <th>Attributes</th><th>Description</th>
* </tr>
* <tr>
* <td>grid_rows</td>
* <td>Indicates the number of rows will be created for the grid form.</td>
* </tr>
* <tr>
* <td>grid_columns</td>
* <td>Indicates the number of columns will be created for the grid form.</td>
* </tr>
* <tr>
* <td>grid_rowWeights</td>
* <td>Specifies the weight of each row in the grid form (default value is 1).</td>
* </tr>
* <tr>
* <td>grid_columnWeights</td>
* <td>Specifies the weight of each column in the grid form (default value is 1).</td>
* </tr>
* <tr>
* <td>grid_spans</td>
* <td>Offers the capability to span a widget across multiple rows and columns</td>
* </tr>
* <tr>
* <td>grid_skips</td>
* <td>Enables skip certain positions in the grid and leave them empty</td>
* </tr>
* <tr>
* <td>grid_orientation</td>
* <td>Defines how the associated widgets will be arranged - vertically or horizontally</td>
* </tr>
* <tr>
* <td>grid_horizontalGaps</td>
* <td>Adds margin horizontally between widgets</td>
* </tr>
* <tr>
* <td>grid_verticalGaps</td>
* <td>Adds margin vertically between widgets</td>
* </tr>
* </table>
*/
public class Grid extends VirtualLayout {
private static final String TAG = "Grid";
public static final int VERTICAL = 1;
public static final int HORIZONTAL = 0;
private final int mMaxRows = 50; // maximum number of rows can be specified.
private final int mMaxColumns = 50; // maximum number of columns can be specified.
private final ConstraintSet mConstraintSet = new ConstraintSet();
ConstraintLayout mContainer;
/**
* number of rows of the grid
*/
private int mRows;
/**
* number of columns of the grid
*/
private int mColumns;
/**
* an Guideline array to store all the vertical guidelines
*/
private Guideline[] mVerticalGuideLines;
/**
* an Guideline array to store all the horizontal guidelines
*/
private Guideline[] mHorizontalGuideLines;
/**
* string format of the input Spans
*/
private String mStrSpans;
/**
* string format of the input Skips
*/
private String mStrSkips;
/**
* string format of the row weight
*/
private String mStrRowWeights;
/**
* string format of the column weight
*/
private String mStrColumnWeights;
/**
* Horizontal gaps in Dp
*/
private float mHorizontalGaps;
/**
* Vertical gaps in Dp
*/
private float mVerticalGaps;
/**
* orientation of the view arrangement - vertical or horizontal
*/
private int mOrientation = 0; // default value is horizontal
/**
* Indicates what is the next available position to place an widget
*/
private int mNextAvailableIndex = 0;
/**
* Indicates whether the input attributes need to be validated
*/
private boolean mValidateInputs;
/**
* Indicates whether to use RTL layout direction
*/
private boolean mUseRtl;
/**
* A integer matrix that tracks the positions that are occupied by skips and spans
* true: available position
* false: non-available position
*/
private boolean[][] mPositionMatrix;
/**
* Store the view ids of handled spans
*/
Set<Integer> mSpanIds = new HashSet<>();
public Grid(Context context) {
super(context);
}
public Grid(Context context, AttributeSet attrs) {
super(context, attrs);
}
public Grid(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void init(AttributeSet attrs) {
super.init(attrs);
// Parse the relevant attributes from layout xml
if (attrs != null) {
TypedArray a = getContext().obtainStyledAttributes(attrs,
R.styleable.Grid);
final int n = a.getIndexCount();
for (int i = 0; i < n; i++) {
int attr = a.getIndex(i);
if (attr == R.styleable.Grid_grid_rows) {
mRows = a.getInteger(attr, 1);
} else if (attr == R.styleable.Grid_grid_columns) {
mColumns = a.getInteger(attr, 1);
} else if (attr == R.styleable.Grid_grid_spans) {
mStrSpans = a.getString(attr);
} else if (attr == R.styleable.Grid_grid_skips) {
mStrSkips = a.getString(attr);
} else if (attr == R.styleable.Grid_grid_rowWeights) {
mStrRowWeights = a.getString(attr);
} else if (attr == R.styleable.Grid_grid_columnWeights) {
mStrColumnWeights = a.getString(attr);
} else if (attr == R.styleable.Grid_grid_orientation) {
mOrientation = a.getInt(attr,0);
} else if (attr == R.styleable.Grid_grid_horizontalGaps) {
mHorizontalGaps = a.getDimension(attr, 0);
} else if (attr == R.styleable.Grid_grid_verticalGaps) {
mVerticalGaps = a.getDimension(attr, 0);
} else if (attr == R.styleable.Grid_grid_validateInputs) {
// @TODO handle validation
mValidateInputs = a.getBoolean(attr, false);
} else if (attr == R.styleable.Grid_grid_useRtl) {
// @TODO handle RTL
mUseRtl = a.getBoolean(attr, false);
}
}
Log.v(TAG, " >>>>>>>>>>> col = "+mColumns);
initVariables();
a.recycle();
}
}
@Override
public void onAttachedToWindow() {
super.onAttachedToWindow();
mContainer = (ConstraintLayout) getParent();
mConstraintSet.clone(mContainer);
generateGrid(false);
}
/**
* generate the Grid form based on the input attributes
* @param isUpdate whether to update the existing grid (true) or create a new one (false)
* @return true if all the inputs are valid else false
*/
private boolean generateGrid(boolean isUpdate) {
if (mContainer == null || mConstraintSet == null) {
return false;
}
if (isUpdate) {
for (int i = 0; i < mPositionMatrix.length; i++) {
for (int j = 0; j < mPositionMatrix[0].length; j++) {
mPositionMatrix[i][j] = true;
}
}
mSpanIds.clear();
}
mNextAvailableIndex = 0;
boolean isSuccess = true;
createGuidelines(mRows, mColumns, isUpdate);
if (mStrSkips != null && !mStrSkips.trim().isEmpty()) {
HashMap<Integer, Pair<Integer, Integer>> mSkipMap = parseSpans(mStrSkips);
if (mSkipMap != null) {
isSuccess &= handleSkips(mSkipMap);
}
}
if (mStrSpans != null && !mStrSpans.trim().isEmpty()) {
HashMap<Integer, Pair<Integer, Integer>> mSpans = parseSpans(mStrSpans);
if (mSpans != null) {
isSuccess &= handleSpans(mIds, mSpans);
}
}
isSuccess &= arrangeWidgets();
mConstraintSet.applyTo(mContainer);
return isSuccess || !mValidateInputs;
}
/**
* Initialize the relevant variables
*/
private void initVariables() {
mPositionMatrix = new boolean[mRows][mColumns];
for (boolean[] row: mPositionMatrix) {
Arrays.fill(row, true);
}
mHorizontalGuideLines = new Guideline[mRows + 1];
mVerticalGuideLines = new Guideline[mColumns + 1];
}
/**
* parse the weights/pads in the string format into a float array
* @param size size of the return array
* @param str weights/pads in a string format
* @return a float array with weights/pads values
*/
private float[] parseWeights(int size, String str) {
if (str == null || str.trim().isEmpty()) {
return null;
}
String[] values = str.split(",");
if (values.length != size) {
return null;
}
float[] arr = new float[size];
for (int i = 0; i < arr.length; i++) {
arr[i] = Float.parseFloat(values[i].trim());
}
return arr;
}
/**
* create vertical and horizontal guidelines based on mRows and mColumns
* @param rows number of rows is required for grid
* @param columns number of columns is required for grid
* @param isUpdate whether to update existing guidelines (true) or create new ones (false)
*/
private void createGuidelines(int rows, int columns, boolean isUpdate) {
float[] rowWeights = parseWeights(rows, mStrRowWeights);
float[] columnWeights = parseWeights(columns, mStrColumnWeights);
float[] horizontalPositions = getLinePositions(0, 1,
rows + 1, rowWeights);
float[] verticalPositions = getLinePositions(0, 1,
columns + 1, columnWeights);
for (int i = 0; i < mHorizontalGuideLines.length; i++) {
if (isUpdate) {
updateGuideLinePosition(mHorizontalGuideLines[i], horizontalPositions[i]);
continue;
}
mHorizontalGuideLines[i] = getNewGuideline(myContext,
ConstraintLayout.LayoutParams.HORIZONTAL, horizontalPositions[i]);
mContainer.addView(mHorizontalGuideLines[i]);
}
for (int i = 0; i < mVerticalGuideLines.length; i++) {
if (isUpdate) {
updateGuideLinePosition(mVerticalGuideLines[i], verticalPositions[i]);
continue;
}
mVerticalGuideLines[i] = getNewGuideline(myContext,
ConstraintLayout.LayoutParams.VERTICAL, verticalPositions[i]);
mContainer.addView(mVerticalGuideLines[i]);
}
}
/**
* get a new Guideline based on the specified orientation and position
* @param context the context
* @param orientation orientation of a Guideline
* @param position position of a Guideline
* @return a Guideline
*/
private Guideline getNewGuideline(Context context, int orientation, float position) {
Guideline guideline = new Guideline(context);
guideline.setId(ViewCompat.generateViewId());
ConstraintLayout.LayoutParams lp =
new ConstraintLayout.LayoutParams(ConstraintLayout.LayoutParams.WRAP_CONTENT,
ConstraintLayout.LayoutParams.WRAP_CONTENT);
lp.orientation = orientation;
lp.guidePercent = position;
guideline.setLayoutParams(lp);
return guideline;
}
private void updateGuideLinePosition(Guideline guideline, float position) {
ConstraintLayout.LayoutParams params =
(ConstraintLayout.LayoutParams) guideline.getLayoutParams();
params.guidePercent = position;
guideline.setLayoutParams(params);
}
/**
* Connect the view to the corresponding guidelines based on the input params
* @param viewId the Id of the view
* @param row row position to place the view
* @param column column position to place the view
*/
private void connectView(int viewId, int row, int column, int rowSpan, int columnSpan,
float horizontalGaps, float verticalGaps) {
// @TODO handle RTL
// connect Start of the view
mConstraintSet.connect(viewId, ConstraintSet.START,
mVerticalGuideLines[column].getId(), ConstraintSet.END,(int) horizontalGaps);
// connect Top of the view
mConstraintSet.connect(viewId, ConstraintSet.TOP,
mHorizontalGuideLines[row].getId(), ConstraintSet.BOTTOM, (int) verticalGaps);
// connect End of the view
mConstraintSet.connect(viewId, ConstraintSet.END,
mVerticalGuideLines[column + columnSpan].getId(),
ConstraintSet.START, (int) horizontalGaps);
// connect Bottom of the view
mConstraintSet.connect(viewId, ConstraintSet.BOTTOM,
mHorizontalGuideLines[row + rowSpan].getId(),
ConstraintSet.TOP,(int) verticalGaps);
}
/**
* Arrange the views in the constraint_referenced_ids
* @return true if all the widgets can be arranged properly else false
*/
private boolean arrangeWidgets() {
Pair<Integer, Integer> position;
// @TODO handle RTL
for (int i = 0; i < mCount; i++) {
if (mSpanIds.contains(mIds[i])) {
// skip the viewId that's already handled by handleSpans
continue;
}
position = getNextPosition();
if (position.first == -1) {
// no more available position.
return false;
}
connectView(mIds[i], position.first, position.second,
1, 1, mHorizontalGaps, mVerticalGaps);
}
return true;
}
/**
* Convert a 1D index to a 2D index that has index for row and index for column
* @param index index in 1D
* @return a Pair with row and column as its values.
*/
private Pair<Integer, Integer> getPositionByIndex(int index) {
// @TODO handle RTL
int row;
int col;
if (mOrientation == 1) {
row = index % mRows;
col = index / mRows;
} else {
row = index / mColumns;
col = index % mColumns;
}
return new Pair<>(row, col);
}
/**
* Get the next available position for widget arrangement.
* @return Pair<row, column>
*/
private Pair<Integer, Integer> getNextPosition() {
Pair<Integer, Integer> position = new Pair<>(0, 0);
boolean positionFound = false;
while (!positionFound) {
if (mNextAvailableIndex >= mRows * mColumns) {
return new Pair<>(-1, -1);
}
position = getPositionByIndex(mNextAvailableIndex);
if (mPositionMatrix[position.first][position.second]) {
mPositionMatrix[position.first][position.second] = false;
positionFound = true;
}
mNextAvailableIndex++;
}
return new Pair<>(position.first, position.second);
}
/**
* Check if the value of the spans/skips is valid
* @param str spans/skips in string format
* @return true if it is valid else false
*/
private boolean isSpansValid(String str) {
// TODO: check string has a valid format.
return true;
}
/**
* Check if the value of the rowWeights or columnsWeights is valid
* @param str rowWeights/columnsWeights in string format
* @return true if it is valid else false
*/
private boolean isWeightsValid(String str) {
// TODO: check string has a valid format.
return true;
}
/**
* parse the skips/spans in the string format into a HashMap<index, row_span, col_span>>
* the format of the input string is index:row_spanxcol_span.
* index - the index of the starting position
* row_span - the number of rows to span
* col_span- the number of columns to span
* @param str string format of skips or spans
* @return a hashmap that contains skip information.
*/
private HashMap<Integer, Pair<Integer, Integer>> parseSpans(String str) {
if (!isSpansValid(str)) {
return null;
}
HashMap<Integer, Pair<Integer, Integer>> skipMap = new HashMap<>();
String[] skips = str.split(",");
String[] indexAndSpan;
String[] rowAndCol;
for (String skip: skips) {
indexAndSpan = skip.trim().split(":");
rowAndCol = indexAndSpan[1].split("x");
skipMap.put(Integer.parseInt(indexAndSpan[0]),
new Pair<>(Integer.parseInt(rowAndCol[0]), Integer.parseInt(rowAndCol[1])));
}
return skipMap;
}
/**
* Handle the span use cases
* @param spansMap a hashmap that contains span information
* @return true if the input spans is valid else false
*/
private boolean handleSpans(int[] mId, HashMap<Integer, Pair<Integer, Integer>> spansMap) {
int mIdIndex = 0;
Pair<Integer, Integer> startPosition;
for (Map.Entry<Integer, Pair<Integer, Integer>> entry : spansMap.entrySet()) {
startPosition = getPositionByIndex(entry.getKey());
if (!invalidatePositions(startPosition.first, startPosition.second,
entry.getValue().first, entry.getValue().second)) {
return false;
}
connectView(mId[mIdIndex], startPosition.first, startPosition.second,
entry.getValue().first, entry.getValue().second,
mHorizontalGaps, mVerticalGaps);
mSpanIds.add(mId[mIdIndex]);
mIdIndex++;
}
return true;
}
/**
* Make positions in the grid unavailable based on the skips attr
* @param skipsMap a hashmap that contains skip information
* @return true if all the skips are valid else false
*/
private boolean handleSkips(HashMap<Integer, Pair<Integer, Integer>> skipsMap) {
Pair<Integer, Integer> startPosition;
for (Map.Entry<Integer, Pair<Integer, Integer>> entry : skipsMap.entrySet()) {
startPosition = getPositionByIndex(entry.getKey());
if (!invalidatePositions(startPosition.first, startPosition.second,
entry.getValue().first, entry.getValue().second)) {
return false;
}
}
return true;
}
/**
* Make the specified positions in the grid unavailable.
* @param startRow the row of the staring position
* @param startColumn the column of the staring position
* @param rowSpan how many rows to span
* @param columnSpan how many columns to span
* @return true if we could properly invalidate the positions esle false
*/
private boolean invalidatePositions(int startRow, int startColumn,
int rowSpan, int columnSpan) {
for (int i = startRow; i < startRow + rowSpan; i++) {
for (int j = startColumn; j < startColumn + columnSpan; j++) {
if (i >= mPositionMatrix.length || j >= mPositionMatrix[0].length
|| !mPositionMatrix[i][j]) {
// the position is already occupied.
return false;
}
mPositionMatrix[i][j] = false;
}
}
return true;
}
/**
* Generate line positions (for the Guideline positioning)
* @param min min value of the linear spaced positions
* * @param max max value of the linear spaced positions
* @param numPositions number of positions is required
* @param weights a float array for space weights
* @return a float array of the corresponding positions
*/
private float[] getLinePositions(float min, float max, int numPositions, float[] weights) {
if (weights != null && numPositions - 1 != weights.length) {
return null;
}
float[] positions = new float[numPositions];
int weightSum = 0;
for (int i = 0; i < numPositions - 1; i++) {
weightSum += weights != null ? weights[i] : 1;
}
float availableSpace = max - min;
float baseWeight = availableSpace / weightSum;
positions[0] = min;
for (int i = 0; i < numPositions - 1; i++) {
float w = weights != null ? weights[i] : 1;
positions[i + 1] = positions[i] + w * baseWeight;
}
return positions;
}
/**
* get the value of rows
* @return the value of rows
*/
public int getRows() {
return mRows;
}
/**
* set new rows value and also invoke initVariables and invalidate
* @param rows new rows value
* @return true if it succeeds otherwise false
*/
public boolean setRows(int rows) {
if (rows < 2 || rows > mMaxRows) {
return false;
}
if (mRows == rows) {
return true;
}
mRows = rows;
initVariables();
generateGrid(false);
invalidate();
return true;
}
/**
* get the value of columns
* @return the value of columns
*/
public int getColumns() {
return mColumns;
}
/**
* set new columns value and also invoke initVariables and invalidate
* @param columns new rows value
* @return true if it succeeds otherwise false
*/
public boolean setColumns(int columns) {
Debug.logStack(TAG, " >>>>>>>>>>>>> col " + columns, 5);
if (columns < 2 || columns > mMaxColumns) {
return false;
}
if (mColumns == columns) {
return true;
}
mColumns = columns;
initVariables();
generateGrid(false);
invalidate();
return true;
}
/**
* get the value of orientation
* @return the value of orientation
*/
public int getOrientation() {
return mOrientation;
}
/**
* set new orientation value and also invoke invalidate
* @param orientation new orientation value
* @return true if it succeeds otherwise false
*/
public boolean setOrientation(int orientation) {
if (!(orientation == HORIZONTAL || orientation == VERTICAL)) {
return false;
}
if (mOrientation == orientation) {
return true;
}
mOrientation = orientation;
generateGrid(true);
invalidate();
return true;
}
/**
* get the string value of spans
* @return the string value of spans
*/
public String getSpans() {
return mStrSpans;
}
/**
* set new spans value and also invoke invalidate
* @param spans new spans value
* @return true if it succeeds otherwise false
*/
public Boolean setSpans(String spans) {
if (!isSpansValid(spans)) {
return false;
}
if (mStrSpans != null && mStrSpans.equals(spans)) {
return true;
}
mStrSpans = spans;
generateGrid(true);
invalidate();
return true;
}
/**
* get the string value of skips
* @return the string value of skips
*/
public String getSkips() {
return mStrSkips;
}
/**
* set new skips value and also invoke invalidate
* @param skips new spans value
* @return true if it succeeds otherwise false
*/
public Boolean setSkips(String skips) {
if (!isSpansValid(skips)) {
return false;
}
if (mStrSkips != null && mStrSkips.equals(skips)) {
return true;
}
mStrSkips = skips;
generateGrid(true);
invalidate();
return true;
}
/**
* get the string value of rowWeights
* @return the string value of rowWeights
*/
public String getRowWeights() {
return mStrRowWeights;
}
/**
* set new rowWeights value and also invoke invalidate
* @param rowWeights new rowWeights value
* @return true if it succeeds otherwise false
*/
public Boolean setRowWeights(String rowWeights) {
if (!isWeightsValid(rowWeights)) {
return false;
}
if (mStrRowWeights != null && mStrRowWeights.equals(rowWeights)) {
return true;
}
mStrRowWeights = rowWeights;
generateGrid(true);
invalidate();
return true;
}
/**
* get the string value of columnWeights
* @return the string value of columnWeights
*/
public String getColumnWeights() {
return mStrColumnWeights;
}
/**
* set new columnWeights value and also invoke invalidate
* @param columnWeights new columnWeights value
* @return true if it succeeds otherwise false
*/
public Boolean setColumnWeights(String columnWeights) {
if (!isWeightsValid(columnWeights)) {
return false;
}
if (mStrColumnWeights != null && mStrColumnWeights.equals(columnWeights)) {
return true;
}
mStrColumnWeights = columnWeights;
generateGrid(true);
invalidate();
return true;
}
/**
* get the value of horizontalGaps
* @return the value of horizontalGaps
*/
public float getHorizontalGaps() {
return mHorizontalGaps;
}
/**
* set new horizontalGaps value and also invoke invalidate
* @param horizontalGaps new horizontalGaps value
* @return true if it succeeds otherwise false
*/
public boolean setHorizontalGaps(float horizontalGaps) {
if (horizontalGaps < 0) {
return false;
}
if (mHorizontalGaps == horizontalGaps) {
return true;
}
mHorizontalGaps = horizontalGaps;
generateGrid(true);
invalidate();
return true;
}
/**
* get the value of verticalGaps
* @return the value of verticalGaps
*/
public float getVerticalGaps() {
return mVerticalGaps;
}
/**
* set new verticalGaps value and also invoke invalidate
* @param verticalGaps new verticalGaps value
* @return true if it succeeds otherwise false
*/
public boolean setVerticalGaps(float verticalGaps) {
if (verticalGaps < 0) {
return false;
}
if (mVerticalGaps == verticalGaps) {
return true;
}
mVerticalGaps = verticalGaps;
generateGrid(true);
invalidate();
return true;
}
}