/* * 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 * *

Grid

* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
AttributesDescription
grid_rowsIndicates the number of rows will be created for the grid form.
grid_columnsIndicates the number of columns will be created for the grid form.
grid_spansOffers the capability to span a widget across multiple rows and columns
grid_skipsEnables skip certain positions in the grid and leave them empty
grid_orientationDefines how the associated widgets will be arranged - vertically or horizontally
grid_horizontalGapsAdds margin horizontally between widgets
grid_verticalGapsAdds margin vertically between widgets
*/ 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 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> 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 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 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 */ private Pair getNextPosition() { Pair 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 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> * 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> parseSkips(String strSkips) { // TODO: check string has a valid format. if (!isSkipsValid(strSkips)) { return null; } HashMap> 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> skipsMap) { Pair startPosition; for (Map.Entry> 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; } }