/*
* 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.Resources;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.util.Log;
import android.util.Pair;
import android.view.View;
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_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";
private static final String VERTICAL = "vertical";
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;
/**
* Horizontal gaps in Dp
*/
private int mHorizontalGaps;
/**
* Vertical gaps in Dp
*/
private int mVerticalGaps;
/**
* orientation of the view arrangement - vertical or horizontal
*/
private String mOrientation;
/**
* 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<>();
/**
* class that stores the relevant span information
*/
static class Span {
int mId;
int mStartRow;
int mStartColumn;
int mRowSpan;
int mColumnSpan;
String mGravity;
Span(int id, int startRow, int startColumn,
int rowSpan, int columnSpan, String gravity) {
this.mId = id;
this.mStartRow = startRow;
this.mStartColumn = startColumn;
this.mRowSpan = rowSpan;
this.mColumnSpan = columnSpan;
this.mGravity = gravity;
}
public int getId() {
return mId;
}
public int getStartRow() {
return mStartRow;
}
public int getStartColumn() {
return mStartColumn;
}
public int getRowSpan() {
return mRowSpan;
}
public int getColumnSpan() {
return mColumnSpan;
}
public String getGravity() {
return mGravity;
}
}
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_orientation) {
mOrientation = a.getString(attr);
} else if (attr == R.styleable.Grid_grid_horizontalGaps) {
mHorizontalGaps = a.getInteger(attr, 0);
} else if (attr == R.styleable.Grid_grid_verticalGaps) {
mVerticalGaps = a.getInteger(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);
}
}
initVariables();
a.recycle();
}
}
@Override
public void onAttachedToWindow() {
super.onAttachedToWindow();
mContainer = (ConstraintLayout) getParent();
mConstraintSet.clone(mContainer);
createGuidelines(mRows, mColumns);
if (mStrSkips != null && !mStrSkips.trim().isEmpty()) {
HashMap<Integer, Pair<Integer, Integer>> mSkipMap = parseSkips(mStrSkips);
if (mSkipMap != null) {
handleSkips(mSkipMap);
}
}
if (mStrSpans != null && !mStrSpans.trim().isEmpty()) {
Span[] mSpans = parseSpans(mStrSpans);
if (mSpans != null) {
handleSpans(mSpans);
}
}
arrangeWidgets();
}
/**
* 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];
}
/**
* 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
*/
private void createGuidelines(int rows, int columns) {
float[] horizontalPositions = getLinspace(0, 1, rows + 1);
float[] verticalPositions = getLinspace(0, 1, columns + 1);
for (int i = 0; i < mHorizontalGuideLines.length; i++) {
mHorizontalGuideLines[i] = getNewGuideline(myContext,
ConstraintLayout.LayoutParams.HORIZONTAL, horizontalPositions[i]);
mContainer.addView(mHorizontalGuideLines[i]);
}
for (int i = 0; i < mVerticalGuideLines.length; i++) {
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;
}
/**
* 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
* @param gravity gravity info, including top, left, bottom, right, guideline,start,end
*/
private void connectView(int viewId, int row, int column, int rowSpan, int columnSpan,
int horizontalGaps, int verticalGaps, String gravity) {
// @TODO handle RTL
// connect Start of the view
mConstraintSet.connect(viewId, ConstraintSet.START,
mVerticalGuideLines[column].getId(), ConstraintSet.END, horizontalGaps);
// connect Top of the view
mConstraintSet.connect(viewId, ConstraintSet.TOP,
mHorizontalGuideLines[row].getId(), ConstraintSet.BOTTOM, verticalGaps);
// connect End of the view
mConstraintSet.connect(viewId, ConstraintSet.END,
mVerticalGuideLines[column + columnSpan].getId(),
ConstraintSet.START, horizontalGaps);
// connect Bottom of the view
mConstraintSet.connect(viewId, ConstraintSet.BOTTOM,
mHorizontalGuideLines[row + rowSpan].getId(),
ConstraintSet.TOP, verticalGaps);
// handle gravity
if (!gravity.trim().equals("")) {
handleGravity(viewId, gravity);
}
mConstraintSet.applyTo(mContainer);
}
/**
* 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.equals(VERTICAL)) {
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);
}
/**
* Handle the gravity. The value could be t, r, b, l, s, e, tl, br, etc.
* t = top, r = right, b = bottom l = left, s = start, e = end
* @param viewId the id of a view
* @param gravity the gravity
*/
private void handleGravity(int viewId, String gravity) {
for (int i = 0; i < gravity.length(); i++) {
// @TODO handle RTL
switch (gravity.charAt(i)) {
case 't':
mConstraintSet.setVerticalBias(viewId, 0);
break;
case 'r':
mConstraintSet.setHorizontalBias(viewId, 1);
break;
case 'b':
mConstraintSet.setVerticalBias(viewId, 1);
break;
case 'l':
mConstraintSet.setHorizontalBias(viewId, 0);
break;
case 's':
mConstraintSet.setHorizontalBias(viewId, 0);
break;
case 'e':
mConstraintSet.setHorizontalBias(viewId, 1);
break;
default:
Log.w(TAG, "unknown gravity value: " + gravity.charAt(i));
}
}
}
/**
* Check if the value of the Spans is valid
* @param mStrSpans spans in string format
* @return true if it is valid else false
*/
private boolean isSpansValid(String mStrSpans) {
// TODO: check string has a valid format.
return true;
}
/**
* Parse the spans in the string format into a span object
* the format of a span is viewId|index:rowSpanxcolumnSpan-gravity
* viewID - The id of a view in the constraint_referenced_ids list
* index - the index of the starting position
* row_span - The number of rows to span
* col_span- The number of columns to span
* gravity (optional) - letters t, l, b, r, s ,e = top, left, bottom, right, start, end.
* Two letters could be used together (e.g., tl, br, etc.)
* @param strSpans Grid spans in the string format
* @return a HashMap contains span information of individual views.
*/
private Span[] parseSpans(String strSpans) {
if (!isSpansValid(strSpans)) {
return null;
}
String[] spans = strSpans.split(",");
Span[] spanArray = new Span[spans.length];
for (int i = 0; i < spans.length; i++) {
String[] idAndRest = spans[i].trim().split(":");
String[] startPositionAndRest = idAndRest[1].split("#");
String[] rowSpanAndRest = startPositionAndRest[1].split("x");
String[] colSpanAndGravity = rowSpanAndRest[1].split("-");
int id = findId(mContainer, idAndRest[0]);
Pair<Integer, Integer> startPosition =
getPositionByIndex(Integer.parseInt(startPositionAndRest[0]));
int rowSpan = Integer.parseInt(rowSpanAndRest[0]);
int columnSpan = Integer.parseInt(colSpanAndGravity[0]);
String gravity = colSpanAndGravity.length > 1 ? colSpanAndGravity[1] : "";
spanArray[i] = new Span(id, startPosition.first, startPosition.second,
rowSpan, columnSpan, gravity);
}
return spanArray;
}
/**
* Handle the span use cases
* @param spans a array of span object
* @return true if the input spans is valid else false
*/
private boolean handleSpans(Span[] spans) {
for (Span span : spans) {
if (!invalidatePositions(span.mStartRow, span.mStartColumn,
span.mRowSpan, span.mColumnSpan)) {
// Try to place the widget to the skipped space
return false;
}
connectView(span.mId, span.mStartRow, span.mStartColumn, span.mRowSpan,
span.mColumnSpan, mHorizontalGaps, mVerticalGaps, span.mGravity);
mSpanIds.add(span.mId);
}
return true;
}
/**
* Check if the value of the skips is valid
* @param mStrSkips skips in string format
* @return true if it is valid else false
*/
private boolean isSkipsValid(String mStrSkips) {
// TODO: check string has a valid format.
return true;
}
/**
* parse the skips 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 strSkips string format of skips
* @return a hashmap that contains skip information.
*/
private HashMap<Integer, Pair<Integer, Integer>> parseSkips(String strSkips) {
// TODO: check string has a valid format.
if (!isSkipsValid(strSkips)) {
return null;
}
HashMap<Integer, Pair<Integer, Integer>> skipMap = new HashMap<>();
String[] skips = strSkips.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;
}
/**
* 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;
}
// From ConstraintHelper -> move to a util function
/**
* Iterate through the container's children to find a matching id.
* Slow path, seems necessary to handle dynamic modules resolution...
*
* @param container the parent container - a ConstraintLayout in this case
* @param idString the string format of a view Id
* @return the actual viewId in Integer
*/
private int findId(ConstraintLayout container, String idString) {
if (idString == null || container == null) {
return 0;
}
Resources resources = myContext.getResources();
if (resources == null) {
return 0;
}
final int count = container.getChildCount();
for (int j = 0; j < count; j++) {
View child = container.getChildAt(j);
if (child.getId() != -1) {
String res = null;
try {
res = resources.getResourceEntryName(child.getId());
} catch (android.content.res.Resources.NotFoundException e) {
// nothing
}
if (idString.equals(res)) {
return child.getId();
}
}
}
return 0;
}
/**
* Generate linearly spaced positions (for the Guideline positioning)
* @param min min value of the linear spaced positions
* @param max max value of the linear spaced positions
* @param positions number of positions in the space
* @return an float array of the corresponding positions
*/
private float[] getLinspace(float min, float max, int positions) {
float[] d = new float[positions];
for (int i = 0; i < positions; i++) {
d[i] = min + i * (max - min) / (positions - 1);
}
return d;
}
/**
* get the string value of spans
* @return the string value of spans
*/
public String getStrSpans() {
return mStrSpans;
}
/**
* get the string value of skips
* @return the string value of skips
*/
public String getStrSkips() {
return mStrSkips;
}
}