/* * Copyright (C) 2011 The Android Open Source Project * * Licensed under the Eclipse Public License, Version 1.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.eclipse.org/org/documents/epl-v10.php * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.ide.common.layout.grid; import static com.android.SdkConstants.ANDROID_URI; import static com.android.SdkConstants.ATTR_COLUMN_COUNT; import static com.android.SdkConstants.ATTR_ID; import static com.android.SdkConstants.ATTR_LAYOUT_COLUMN; import static com.android.SdkConstants.ATTR_LAYOUT_COLUMN_SPAN; import static com.android.SdkConstants.ATTR_LAYOUT_GRAVITY; import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; import static com.android.SdkConstants.ATTR_LAYOUT_ROW; import static com.android.SdkConstants.ATTR_LAYOUT_ROW_SPAN; import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; import static com.android.SdkConstants.ATTR_ORIENTATION; import static com.android.SdkConstants.ATTR_ROW_COUNT; import static com.android.SdkConstants.FQCN_GRID_LAYOUT; import static com.android.SdkConstants.FQCN_SPACE; import static com.android.SdkConstants.FQCN_SPACE_V7; import static com.android.SdkConstants.GRID_LAYOUT; import static com.android.SdkConstants.NEW_ID_PREFIX; import static com.android.SdkConstants.SPACE; import static com.android.SdkConstants.VALUE_BOTTOM; import static com.android.SdkConstants.VALUE_CENTER_VERTICAL; import static com.android.SdkConstants.VALUE_N_DP; import static com.android.SdkConstants.VALUE_TOP; import static com.android.SdkConstants.VALUE_VERTICAL; import static com.android.ide.common.layout.GravityHelper.GRAVITY_BOTTOM; import static com.android.ide.common.layout.GravityHelper.GRAVITY_CENTER_HORIZ; import static com.android.ide.common.layout.GravityHelper.GRAVITY_CENTER_VERT; import static com.android.ide.common.layout.GravityHelper.GRAVITY_RIGHT; import static java.lang.Math.abs; import static java.lang.Math.max; import static java.lang.Math.min; import com.android.annotations.NonNull; import com.android.annotations.Nullable; import com.android.ide.common.api.IClientRulesEngine; import com.android.ide.common.api.INode; import com.android.ide.common.api.IViewMetadata; import com.android.ide.common.api.Margins; import com.android.ide.common.api.Rect; import com.android.ide.common.layout.GravityHelper; import com.android.ide.common.layout.GridLayoutRule; import com.android.utils.Pair; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Multimap; import java.io.PrintWriter; import java.io.StringWriter; import java.lang.ref.WeakReference; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; /** Models a GridLayout */ public class GridModel { /** Marker value used to indicate values (rows, columns, etc) which have not been set */ static final int UNDEFINED = Integer.MIN_VALUE; /** The size of spacers in the dimension that they are not defining */ private static final int SPACER_SIZE_DP = 1; /** Attribute value used for {@link #SPACER_SIZE_DP} */ private static final String SPACER_SIZE = String.format(VALUE_N_DP, SPACER_SIZE_DP); /** Width assigned to a newly added column with the Add Column action */ private static final int DEFAULT_CELL_WIDTH = 100; /** Height assigned to a newly added row with the Add Row action */ private static final int DEFAULT_CELL_HEIGHT = 15; /** The GridLayout node, never null */ public final INode layout; /** True if this is a vertical layout, and false if it is horizontal (the default) */ public boolean vertical; /** The declared count of rows (which may be {@link #UNDEFINED} if not specified) */ public int declaredRowCount; /** The declared count of columns (which may be {@link #UNDEFINED} if not specified) */ public int declaredColumnCount; /** The actual count of rows found in the grid */ public int actualRowCount; /** The actual count of columns found in the grid */ public int actualColumnCount; /** * Array of positions (indexed by column) of the left edge of table cells; this * corresponds to the column positions in the grid */ private int[] mLeft; /** * Array of positions (indexed by row) of the top edge of table cells; this * corresponds to the row positions in the grid */ private int[] mTop; /** * Array of positions (indexed by column) of the maximum right hand side bounds of a * node in the given column; this represents the visual edge of a column even when the * actual column is wider */ private int[] mMaxRight; /** * Array of positions (indexed by row) of the maximum bottom bounds of a node in the * given row; this represents the visual edge of a row even when the actual row is * taller */ private int[] mMaxBottom; /** * Array of baselines computed for the rows. This array is populated lazily and should * not be accessed directly; call {@link #getBaseline(int)} instead. */ private int[] mBaselines; /** List of all the view data for the children in this layout */ private List<ViewData> mChildViews; /** The {@link IClientRulesEngine} */ private final IClientRulesEngine mRulesEngine; /** * An actual instance of a GridLayout object that this grid model corresponds to. */ private Object mViewObject; /** The namespace to use for attributes */ private String mNamespace; /** * Constructs a {@link GridModel} for the given layout * * @param rulesEngine the associated rules engine * @param node the GridLayout node * @param viewObject an actual GridLayout instance, or null */ private GridModel(IClientRulesEngine rulesEngine, INode node, Object viewObject) { mRulesEngine = rulesEngine; layout = node; mViewObject = viewObject; loadFromXml(); } // Factory cache for most recent item (used primarily because during paints and drags // the grid model is called repeatedly for the same view object.) private static WeakReference<Object> sCachedViewObject = new WeakReference<Object>(null); private static WeakReference<GridModel> sCachedViewModel; /** * Factory which returns a grid model for the given node. * * @param rulesEngine the associated rules engine * @param node the GridLayout node * @param viewObject an actual GridLayout instance, or null * @return a new model */ @NonNull public static GridModel get( @NonNull IClientRulesEngine rulesEngine, @NonNull INode node, @Nullable Object viewObject) { if (viewObject != null && viewObject == sCachedViewObject.get()) { GridModel model = sCachedViewModel.get(); if (model != null) { return model; } } GridModel model = new GridModel(rulesEngine, node, viewObject); sCachedViewModel = new WeakReference<GridModel>(model); sCachedViewObject = new WeakReference<Object>(viewObject); return model; } /** * Returns the {@link ViewData} for the child at the given index * * @param index the position of the child node whose view we want to look up * @return the corresponding {@link ViewData} */ public ViewData getView(int index) { return mChildViews.get(index); } /** * Returns the {@link ViewData} for the given child node. * * @param node the node for which we want the view info * @return the view info for the node, or null if not found */ public ViewData getView(INode node) { for (ViewData view : mChildViews) { if (view.node == node) { return view; } } return null; } /** * Computes the index (among the children nodes) to insert a new node into which * should be positioned at the given row and column. This will skip over any nodes * that have implicit positions earlier than the given node, and will also ensure that * all nodes are placed before the spacer nodes. * * @param row the target row of the new node * @param column the target column of the new node * @return the insert position to use or -1 if no preference is found */ public int getInsertIndex(int row, int column) { if (vertical) { for (ViewData view : mChildViews) { if (view.column > column || view.column == column && view.row >= row) { return view.index; } } } else { for (ViewData view : mChildViews) { if (view.row > row || view.row == row && view.column >= column) { return view.index; } } } // Place it before the first spacer for (ViewData view : mChildViews) { if (view.isSpacer()) { return view.index; } } return -1; } /** * Returns the baseline of the given row, or -1 if none is found. This looks for views * in the row which have baseline vertical alignment and also define their own * baseline, and returns the first such match. * * @param row the row to look up a baseline for * @return the baseline relative to the row position, or -1 if not defined */ public int getBaseline(int row) { if (row < 0 || row >= mBaselines.length) { return -1; } int baseline = mBaselines[row]; if (baseline == UNDEFINED) { baseline = -1; // TBD: Consider stringing together row information in the view data // so I can quickly identify the views in a given row instead of searching // among all? for (ViewData view : mChildViews) { // We only count baselines for views with rowSpan=1 because // baseline alignment doesn't work for cell spanning views if (view.row == row && view.rowSpan == 1) { baseline = view.node.getBaseline(); if (baseline != -1) { // Even views that do have baselines do not count towards a row // baseline if they have a vertical gravity String gravity = getGridAttribute(view.node, ATTR_LAYOUT_GRAVITY); if (gravity == null || !(gravity.contains(VALUE_TOP) || gravity.contains(VALUE_BOTTOM) || gravity.contains(VALUE_CENTER_VERTICAL))) { // Compute baseline relative to the row, not the view itself baseline += view.node.getBounds().y - getRowY(row); break; } } } } mBaselines[row] = baseline; } return baseline; } /** Applies the row and column values into the XML */ void applyPositionAttributes() { for (ViewData view : mChildViews) { view.applyPositionAttributes(); } // Also fix the columnCount if (getGridAttribute(layout, ATTR_COLUMN_COUNT) != null && declaredColumnCount > actualColumnCount) { setGridAttribute(layout, ATTR_COLUMN_COUNT, actualColumnCount); } } /** * Sets the given GridLayout attribute (rowCount, layout_row, etc) to the * given value. This automatically handles using the right XML namespace * based on whether the GridLayout is the android.widget.GridLayout, or the * support library GridLayout, and whether it's in a library project or not * etc. * * @param node the node to apply the attribute to * @param name the local name of the attribute * @param value the integer value to set the attribute to */ public void setGridAttribute(INode node, String name, int value) { setGridAttribute(node, name, Integer.toString(value)); } /** * Sets the given GridLayout attribute (rowCount, layout_row, etc) to the * given value. This automatically handles using the right XML namespace * based on whether the GridLayout is the android.widget.GridLayout, or the * support library GridLayout, and whether it's in a library project or not * etc. * * @param node the node to apply the attribute to * @param name the local name of the attribute * @param value the string value to set the attribute to, or null to clear * it */ public void setGridAttribute(INode node, String name, String value) { node.setAttribute(getNamespace(), name, value); } /** * Returns the namespace URI to use for GridLayout-specific attributes, such * as columnCount, layout_column, layout_column_span, layout_gravity etc. * * @return the namespace, never null */ public String getNamespace() { if (mNamespace == null) { mNamespace = ANDROID_URI; if (!layout.getFqcn().equals(FQCN_GRID_LAYOUT)) { mNamespace = mRulesEngine.getAppNameSpace(); } } return mNamespace; } /** Removes the given flag from a flag attribute value and returns the result */ static String removeFlag(String flag, String value) { if (value.equals(flag)) { return null; } // Handle spaces between pipes and flag are a prefix, suffix and interior occurrences int index = value.indexOf(flag); if (index != -1) { int pipe = value.lastIndexOf('|', index); int endIndex = index + flag.length(); if (pipe != -1) { value = value.substring(0, pipe).trim() + value.substring(endIndex).trim(); } else { pipe = value.indexOf('|', endIndex); if (pipe != -1) { value = value.substring(0, index).trim() + value.substring(pipe + 1).trim(); } else { value = value.substring(0, index).trim() + value.substring(endIndex).trim(); } } } return value; } /** * Loads a {@link GridModel} from the XML model. */ private void loadFromXml() { INode[] children = layout.getChildren(); declaredRowCount = getGridAttribute(layout, ATTR_ROW_COUNT, UNDEFINED); declaredColumnCount = getGridAttribute(layout, ATTR_COLUMN_COUNT, UNDEFINED); // Horizontal is the default, so if no value is specified it is horizontal. vertical = VALUE_VERTICAL.equals(getGridAttribute(layout, ATTR_ORIENTATION)); mChildViews = new ArrayList<ViewData>(children.length); int index = 0; for (INode child : children) { ViewData view = new ViewData(child, index++); mChildViews.add(view); } // Assign row/column positions to all cells that do not explicitly define them if (!assignRowsAndColumnsFromViews(mChildViews)) { assignRowsAndColumnsFromXml( declaredRowCount == UNDEFINED ? children.length : declaredRowCount, declaredColumnCount == UNDEFINED ? children.length : declaredColumnCount); } assignCellBounds(); for (int i = 0; i <= actualRowCount; i++) { mBaselines[i] = UNDEFINED; } } private Pair<Map<Integer, Integer>, Map<Integer, Integer>> findCellsOutsideDeclaredBounds() { // See if we have any (row,column) pairs that fall outside the declared // bounds; for these we identify the number of unique values and assign these // consecutive values Map<Integer, Integer> extraColumnsMap = null; Map<Integer, Integer> extraRowsMap = null; if (declaredRowCount != UNDEFINED) { Set<Integer> extraRows = null; for (ViewData view : mChildViews) { if (view.row >= declaredRowCount) { if (extraRows == null) { extraRows = new HashSet<Integer>(); } extraRows.add(view.row); } } if (extraRows != null && declaredRowCount != UNDEFINED) { List<Integer> rows = new ArrayList<Integer>(extraRows); Collections.sort(rows); int row = declaredRowCount; extraRowsMap = new HashMap<Integer, Integer>(); for (Integer declared : rows) { extraRowsMap.put(declared, row++); } } } if (declaredColumnCount != UNDEFINED) { Set<Integer> extraColumns = null; for (ViewData view : mChildViews) { if (view.column >= declaredColumnCount) { if (extraColumns == null) { extraColumns = new HashSet<Integer>(); } extraColumns.add(view.column); } } if (extraColumns != null && declaredColumnCount != UNDEFINED) { List<Integer> columns = new ArrayList<Integer>(extraColumns); Collections.sort(columns); int column = declaredColumnCount; extraColumnsMap = new HashMap<Integer, Integer>(); for (Integer declared : columns) { extraColumnsMap.put(declared, column++); } } } return Pair.of(extraRowsMap, extraColumnsMap); } /** * Figure out actual row and column numbers for views that do not specify explicit row * and/or column numbers * TODO: Consolidate with the algorithm in GridLayout to ensure we get the * exact same results! */ private void assignRowsAndColumnsFromXml(int rowCount, int columnCount) { Pair<Map<Integer, Integer>, Map<Integer, Integer>> p = findCellsOutsideDeclaredBounds(); Map<Integer, Integer> extraRowsMap = p.getFirst(); Map<Integer, Integer> extraColumnsMap = p.getSecond(); if (!vertical) { // Horizontal GridLayout: this is the default. Row and column numbers // are assigned by assuming that the children are assigned successive // column numbers until we get to the column count of the grid, at which // point we jump to the next row. If any cell specifies either an explicit // row number of column number, we jump to the next available position. // Note also that if there are any rowspans on the current row, then the // next row we jump to is below the largest such rowspan - in other words, // the algorithm does not fill holes in the middle! // TODO: Ensure that we don't run into trouble if a later element specifies // an earlier number... find out what the layout does in that case! int row = 0; int column = 0; int nextRow = 1; for (ViewData view : mChildViews) { int declaredColumn = view.column; if (declaredColumn != UNDEFINED) { if (declaredColumn >= columnCount) { assert extraColumnsMap != null; declaredColumn = extraColumnsMap.get(declaredColumn); view.column = declaredColumn; } if (declaredColumn < column) { // Must jump to the next row to accommodate the new row assert nextRow > row; //row++; row = nextRow; } column = declaredColumn; } else { view.column = column; } if (view.row != UNDEFINED) { // TODO: Should this adjust the column number too? (If so must // also update view.column since we've already processed the local // column number) row = view.row; } else { view.row = row; } nextRow = Math.max(nextRow, view.row + view.rowSpan); // Advance column += view.columnSpan; if (column >= columnCount) { column = 0; assert nextRow > row; //row++; row = nextRow; } } } else { // Vertical layout: successive children are assigned to the same column in // successive rows. int row = 0; int column = 0; int nextColumn = 1; for (ViewData view : mChildViews) { int declaredRow = view.row; if (declaredRow != UNDEFINED) { if (declaredRow >= rowCount) { declaredRow = extraRowsMap.get(declaredRow); view.row = declaredRow; } if (declaredRow < row) { // Must jump to the next column to accommodate the new column assert nextColumn > column; column = nextColumn; } row = declaredRow; } else { view.row = row; } if (view.column != UNDEFINED) { // TODO: Should this adjust the row number too? (If so must // also update view.row since we've already processed the local // row number) column = view.column; } else { view.column = column; } nextColumn = Math.max(nextColumn, view.column + view.columnSpan); // Advance row += view.rowSpan; if (row >= rowCount) { row = 0; assert nextColumn > column; //row++; column = nextColumn; } } } } private static boolean sAttemptSpecReflection = true; private boolean assignRowsAndColumnsFromViews(List<ViewData> views) { if (!sAttemptSpecReflection) { return false; } try { // Lazily initialized reflection methods Field spanField = null; Field rowSpecField = null; Field colSpecField = null; Field minField = null; Field maxField = null; Method getLayoutParams = null; for (ViewData view : views) { // TODO: If the element *specifies* anything in XML, use that instead Object child = mRulesEngine.getViewObject(view.node); if (child == null) { // Fallback to XML model return false; } if (getLayoutParams == null) { getLayoutParams = child.getClass().getMethod("getLayoutParams"); //$NON-NLS-1$ } Object layoutParams = getLayoutParams.invoke(child); if (rowSpecField == null) { Class<? extends Object> layoutParamsClass = layoutParams.getClass(); rowSpecField = layoutParamsClass.getDeclaredField("rowSpec"); //$NON-NLS-1$ colSpecField = layoutParamsClass.getDeclaredField("columnSpec"); //$NON-NLS-1$ rowSpecField.setAccessible(true); colSpecField.setAccessible(true); } assert colSpecField != null; Object rowSpec = rowSpecField.get(layoutParams); Object colSpec = colSpecField.get(layoutParams); if (spanField == null) { spanField = rowSpec.getClass().getDeclaredField("span"); //$NON-NLS-1$ spanField.setAccessible(true); } assert spanField != null; Object rowInterval = spanField.get(rowSpec); Object colInterval = spanField.get(colSpec); if (minField == null) { Class<? extends Object> intervalClass = rowInterval.getClass(); minField = intervalClass.getDeclaredField("min"); //$NON-NLS-1$ maxField = intervalClass.getDeclaredField("max"); //$NON-NLS-1$ minField.setAccessible(true); maxField.setAccessible(true); } assert maxField != null; int row = minField.getInt(rowInterval); int col = minField.getInt(colInterval); int rowEnd = maxField.getInt(rowInterval); int colEnd = maxField.getInt(colInterval); view.column = col; view.row = row; view.columnSpan = colEnd - col; view.rowSpan = rowEnd - row; } return true; } catch (Throwable e) { sAttemptSpecReflection = false; return false; } } /** * Computes the positions of the column and row boundaries */ private void assignCellBounds() { if (!assignCellBoundsFromView()) { assignCellBoundsFromBounds(); } initializeMaxBounds(); mBaselines = new int[actualRowCount + 1]; } /** * Computes the positions of the column and row boundaries, using actual * layout data from the associated GridLayout instance (stored in * {@link #mViewObject}) */ private boolean assignCellBoundsFromView() { if (mViewObject != null) { Pair<int[], int[]> cellBounds = GridModel.getAxisBounds(mViewObject); if (cellBounds != null) { int[] xs = cellBounds.getFirst(); int[] ys = cellBounds.getSecond(); actualColumnCount = xs.length - 1; actualRowCount = ys.length - 1; Rect layoutBounds = layout.getBounds(); int layoutBoundsX = layoutBounds.x; int layoutBoundsY = layoutBounds.y; mLeft = new int[xs.length]; mTop = new int[ys.length]; for (int i = 0; i < xs.length; i++) { mLeft[i] = xs[i] + layoutBoundsX; } for (int i = 0; i < ys.length; i++) { mTop[i] = ys[i] + layoutBoundsY; } return true; } } return false; } /** * Computes the boundaries of the rows and columns by considering the bounds of the * children. */ private void assignCellBoundsFromBounds() { Rect layoutBounds = layout.getBounds(); // Compute the actualColumnCount and actualRowCount. This -should- be // as easy as declaredColumnCount + extraColumnsMap.size(), // but the user doesn't *have* to declare a column count (or a row count) // and we need both, so go and find the actual row and column maximums. int maxColumn = 0; int maxRow = 0; for (ViewData view : mChildViews) { maxColumn = max(maxColumn, view.column); maxRow = max(maxRow, view.row); } actualColumnCount = maxColumn + 1; actualRowCount = maxRow + 1; mLeft = new int[actualColumnCount + 1]; for (int i = 1; i < actualColumnCount; i++) { mLeft[i] = UNDEFINED; } mLeft[0] = layoutBounds.x; mLeft[actualColumnCount] = layoutBounds.x2(); mTop = new int[actualRowCount + 1]; for (int i = 1; i < actualRowCount; i++) { mTop[i] = UNDEFINED; } mTop[0] = layoutBounds.y; mTop[actualRowCount] = layoutBounds.y2(); for (ViewData view : mChildViews) { Rect bounds = view.node.getBounds(); if (!bounds.isValid()) { continue; } int column = view.column; int row = view.row; if (mLeft[column] == UNDEFINED) { mLeft[column] = bounds.x; } else { mLeft[column] = Math.min(bounds.x, mLeft[column]); } if (mTop[row] == UNDEFINED) { mTop[row] = bounds.y; } else { mTop[row] = Math.min(bounds.y, mTop[row]); } } // Ensure that any empty columns/rows have a valid boundary value; for now, for (int i = actualColumnCount - 1; i >= 0; i--) { if (mLeft[i] == UNDEFINED) { if (i == 0) { mLeft[i] = layoutBounds.x; } else if (i < actualColumnCount - 1) { mLeft[i] = mLeft[i + 1] - 1; if (mLeft[i - 1] != UNDEFINED && mLeft[i] < mLeft[i - 1]) { mLeft[i] = mLeft[i - 1]; } } else { mLeft[i] = layoutBounds.x2(); } } } for (int i = actualRowCount - 1; i >= 0; i--) { if (mTop[i] == UNDEFINED) { if (i == 0) { mTop[i] = layoutBounds.y; } else if (i < actualRowCount - 1) { mTop[i] = mTop[i + 1] - 1; if (mTop[i - 1] != UNDEFINED && mTop[i] < mTop[i - 1]) { mTop[i] = mTop[i - 1]; } } else { mTop[i] = layoutBounds.y2(); } } } // The bounds should be in ascending order now if (false && GridLayoutRule.sDebugGridLayout) { for (int i = 1; i < actualRowCount; i++) { assert mTop[i + 1] >= mTop[i]; } for (int i = 0; i < actualColumnCount; i++) { assert mLeft[i + 1] >= mLeft[i]; } } } /** * Determine, for each row and column, what the largest x and y edges are * within that row or column. This is used to find a natural split point to * suggest when adding something "to the right of" or "below" another view. */ private void initializeMaxBounds() { mMaxRight = new int[actualColumnCount + 1]; mMaxBottom = new int[actualRowCount + 1]; for (ViewData view : mChildViews) { Rect bounds = view.node.getBounds(); if (!bounds.isValid()) { continue; } if (!view.isSpacer()) { int x2 = bounds.x2(); int y2 = bounds.y2(); int column = view.column; int row = view.row; int targetColumn = min(actualColumnCount - 1, column + view.columnSpan - 1); int targetRow = min(actualRowCount - 1, row + view.rowSpan - 1); IViewMetadata metadata = mRulesEngine.getMetadata(view.node.getFqcn()); if (metadata != null) { Margins insets = metadata.getInsets(); if (insets != null) { x2 -= insets.right; y2 -= insets.bottom; } } if (mMaxRight[targetColumn] < x2 && ((view.gravity & (GRAVITY_CENTER_HORIZ | GRAVITY_RIGHT)) == 0)) { mMaxRight[targetColumn] = x2; } if (mMaxBottom[targetRow] < y2 && ((view.gravity & (GRAVITY_CENTER_VERT | GRAVITY_BOTTOM)) == 0)) { mMaxBottom[targetRow] = y2; } } } } /** * Looks up the x[] and y[] locations of the columns and rows in the given GridLayout * instance. * * @param view the GridLayout object, which should already have performed layout * @return a pair of x[] and y[] integer arrays, or null if it could not be found */ public static Pair<int[], int[]> getAxisBounds(Object view) { try { Class<?> clz = view.getClass(); Field horizontalAxis = clz.getDeclaredField("horizontalAxis"); //$NON-NLS-1$ Field verticalAxis = clz.getDeclaredField("verticalAxis"); //$NON-NLS-1$ horizontalAxis.setAccessible(true); verticalAxis.setAccessible(true); Object horizontal = horizontalAxis.get(view); Object vertical = verticalAxis.get(view); Field locations = horizontal.getClass().getDeclaredField("locations"); //$NON-NLS-1$ assert locations.getType().isArray() : locations.getType(); locations.setAccessible(true); Object horizontalLocations = locations.get(horizontal); Object verticalLocations = locations.get(vertical); int[] xs = (int[]) horizontalLocations; int[] ys = (int[]) verticalLocations; return Pair.of(xs, ys); } catch (Throwable t) { // Probably trying to show a GridLayout on a platform that does not support it. // Return null to indicate that the grid bounds must be computed from view bounds. return null; } } /** * Add a new column. * * @param selectedChildren if null or empty, add the column at the end of the grid, * and otherwise add it before the column of the first selected child * @return the newly added column spacer */ public INode addColumn(List<? extends INode> selectedChildren) { // Determine insert index int newColumn = actualColumnCount; if (selectedChildren != null && selectedChildren.size() > 0) { INode first = selectedChildren.get(0); ViewData view = getView(first); newColumn = view.column; } INode newView = addColumn(newColumn, null, UNDEFINED, false, UNDEFINED, UNDEFINED); if (newView != null) { mRulesEngine.select(Collections.singletonList(newView)); } return newView; } /** * Adds a new column. * * @param newColumn the column index to insert before * @param newView the {@link INode} to insert as the column spacer, which may be null * (in which case a spacer is automatically created) * @param columnWidthDp the width, in device independent pixels, of the column to be * added (which may be {@link #UNDEFINED} * @param split if true, split the existing column into two at the given x position * @param row the row to add the newView to * @param x the x position of the column we're inserting * @return the column spacer */ public INode addColumn(int newColumn, INode newView, int columnWidthDp, boolean split, int row, int x) { // Insert a new column actualColumnCount++; if (declaredColumnCount != UNDEFINED) { declaredColumnCount++; setGridAttribute(layout, ATTR_COLUMN_COUNT, declaredColumnCount); } boolean isLastColumn = true; for (ViewData view : mChildViews) { if (view.column >= newColumn) { isLastColumn = false; break; } } for (ViewData view : mChildViews) { boolean columnSpanSet = false; int endColumn = view.column + view.columnSpan; if (view.column >= newColumn || endColumn == newColumn) { if (view.column == newColumn || endColumn == newColumn) { //if (view.row == 0) { if (newView == null && !isLastColumn) { // Insert a new spacer int index = getChildIndex(layout.getChildren(), view.node); assert view.index == index; // TODO: Get rid of getter if (endColumn == newColumn) { // This cell -ends- at the desired position: insert it after index++; } ViewData newViewData = addSpacer(layout, index, split ? row : UNDEFINED, split ? newColumn - 1 : UNDEFINED, columnWidthDp != UNDEFINED ? columnWidthDp : DEFAULT_CELL_WIDTH, DEFAULT_CELL_HEIGHT); newViewData.column = newColumn - 1; newViewData.row = row; newView = newViewData.node; } // Set the actual row number on the first cell on the new row. // This means we don't really need the spacer above to imply // the new row number, but we use the spacer to assign the row // some height. if (view.column == newColumn) { view.column++; setGridAttribute(view.node, ATTR_LAYOUT_COLUMN, view.column); } // else: endColumn == newColumn: handled below } else if (getGridAttribute(view.node, ATTR_LAYOUT_COLUMN) != null) { view.column++; setGridAttribute(view.node, ATTR_LAYOUT_COLUMN, view.column); } } else if (endColumn > newColumn) { view.columnSpan++; setColumnSpanAttribute(view.node, view.columnSpan); columnSpanSet = true; } if (split && !columnSpanSet && view.node.getBounds().x2() > x) { if (view.node.getBounds().x < x) { view.columnSpan++; setColumnSpanAttribute(view.node, view.columnSpan); } } } // Hardcode the row numbers if the last column is a new column such that // they don't jump back to backfill the previous row's new last cell if (isLastColumn) { for (ViewData view : mChildViews) { if (view.column == 0 && view.row > 0) { setGridAttribute(view.node, ATTR_LAYOUT_ROW, view.row); } } if (split) { assert newView == null; addSpacer(layout, -1, row, newColumn -1, columnWidthDp != UNDEFINED ? columnWidthDp : DEFAULT_CELL_WIDTH, SPACER_SIZE_DP); } } return newView; } /** * Removes the columns containing the given selection * * @param selectedChildren a list of nodes whose columns should be deleted */ public void removeColumns(List<? extends INode> selectedChildren) { if (selectedChildren.size() == 0) { return; } // Figure out which columns should be removed Set<Integer> removeColumns = new HashSet<Integer>(); Set<ViewData> removedViews = new HashSet<ViewData>(); for (INode child : selectedChildren) { ViewData view = getView(child); removedViews.add(view); removeColumns.add(view.column); } // Sort them in descending order such that we can process each // deletion independently List<Integer> removed = new ArrayList<Integer>(removeColumns); Collections.sort(removed, Collections.reverseOrder()); for (int removedColumn : removed) { // Remove column. // First, adjust column count. // TODO: Don't do this if the column being deleted is outside // the declared column range! // TODO: Do this under a write lock? / editXml lock? actualColumnCount--; if (declaredColumnCount != UNDEFINED) { declaredColumnCount--; } // Remove any elements that begin in the deleted columns... // If they have colspan > 1, then we must insert a spacer instead. // For any other elements that overlap, we need to subtract from the span. for (ViewData view : mChildViews) { if (view.column == removedColumn) { int index = getChildIndex(layout.getChildren(), view.node); assert view.index == index; // TODO: Get rid of getter if (view.columnSpan > 1) { // Make a new spacer which is the width of the following // columns int columnWidth = getColumnWidth(removedColumn, view.columnSpan) - getColumnWidth(removedColumn, 1); int columnWidthDip = mRulesEngine.pxToDp(columnWidth); ViewData spacer = addSpacer(layout, index, UNDEFINED, UNDEFINED, columnWidthDip, SPACER_SIZE_DP); spacer.row = 0; spacer.column = removedColumn; } layout.removeChild(view.node); } else if (view.column < removedColumn && view.column + view.columnSpan > removedColumn) { // Subtract column span to skip this item view.columnSpan--; setColumnSpanAttribute(view.node, view.columnSpan); } else if (view.column > removedColumn) { view.column--; if (getGridAttribute(view.node, ATTR_LAYOUT_COLUMN) != null) { setGridAttribute(view.node, ATTR_LAYOUT_COLUMN, view.column); } } } } // Remove children from child list! if (removedViews.size() <= 2) { mChildViews.removeAll(removedViews); } else { List<ViewData> remaining = new ArrayList<ViewData>(mChildViews.size() - removedViews.size()); for (ViewData view : mChildViews) { if (!removedViews.contains(view)) { remaining.add(view); } } mChildViews = remaining; } //if (declaredColumnCount != UNDEFINED) { setGridAttribute(layout, ATTR_COLUMN_COUNT, actualColumnCount); //} } /** * Add a new row. * * @param selectedChildren if null or empty, add the row at the bottom of the grid, * and otherwise add it before the row of the first selected child * @return the newly added row spacer */ public INode addRow(List<? extends INode> selectedChildren) { // Determine insert index int newRow = actualRowCount; if (selectedChildren.size() > 0) { INode first = selectedChildren.get(0); ViewData view = getView(first); newRow = view.row; } INode newView = addRow(newRow, null, UNDEFINED, false, UNDEFINED, UNDEFINED); if (newView != null) { mRulesEngine.select(Collections.singletonList(newView)); } return newView; } /** * Adds a new column. * * @param newRow the row index to insert before * @param newView the {@link INode} to insert as the row spacer, which may be null (in * which case a spacer is automatically created) * @param rowHeightDp the height, in device independent pixels, of the row to be added * (which may be {@link #UNDEFINED} * @param split if true, split the existing row into two at the given y position * @param column the column to add the newView to * @param y the y position of the row we're inserting * @return the row spacer */ public INode addRow(int newRow, INode newView, int rowHeightDp, boolean split, int column, int y) { actualRowCount++; if (declaredRowCount != UNDEFINED) { declaredRowCount++; setGridAttribute(layout, ATTR_ROW_COUNT, declaredRowCount); } boolean added = false; for (ViewData view : mChildViews) { if (view.row >= newRow) { // Adjust the column count if (view.row == newRow && view.column == 0) { // Insert a new spacer if (newView == null) { int index = getChildIndex(layout.getChildren(), view.node); assert view.index == index; // TODO: Get rid of getter if (declaredColumnCount != UNDEFINED && !split) { setGridAttribute(layout, ATTR_COLUMN_COUNT, declaredColumnCount); } ViewData newViewData = addSpacer(layout, index, split ? newRow - 1 : UNDEFINED, split ? column : UNDEFINED, SPACER_SIZE_DP, rowHeightDp != UNDEFINED ? rowHeightDp : DEFAULT_CELL_HEIGHT); newViewData.column = column; newViewData.row = newRow - 1; newView = newViewData.node; } // Set the actual row number on the first cell on the new row. // This means we don't really need the spacer above to imply // the new row number, but we use the spacer to assign the row // some height. view.row++; setGridAttribute(view.node, ATTR_LAYOUT_ROW, view.row); added = true; } else if (getGridAttribute(view.node, ATTR_LAYOUT_ROW) != null) { view.row++; setGridAttribute(view.node, ATTR_LAYOUT_ROW, view.row); } } else { int endRow = view.row + view.rowSpan; if (endRow > newRow) { view.rowSpan++; setRowSpanAttribute(view.node, view.rowSpan); } else if (split && view.node.getBounds().y2() > y) { if (view.node.getBounds().y < y) { view.rowSpan++; setRowSpanAttribute(view.node, view.rowSpan); } } } } if (!added) { // Append a row at the end if (newView == null) { ViewData newViewData = addSpacer(layout, -1, UNDEFINED, UNDEFINED, SPACER_SIZE_DP, rowHeightDp != UNDEFINED ? rowHeightDp : DEFAULT_CELL_HEIGHT); newViewData.column = column; // TODO: MAke sure this row number is right! newViewData.row = split ? newRow - 1 : newRow; newView = newViewData.node; } if (declaredColumnCount != UNDEFINED && !split) { setGridAttribute(layout, ATTR_COLUMN_COUNT, declaredColumnCount); } if (split) { setGridAttribute(newView, ATTR_LAYOUT_ROW, newRow - 1); setGridAttribute(newView, ATTR_LAYOUT_COLUMN, column); } } return newView; } /** * Removes the rows containing the given selection * * @param selectedChildren a list of nodes whose rows should be deleted */ public void removeRows(List<? extends INode> selectedChildren) { if (selectedChildren.size() == 0) { return; } // Figure out which rows should be removed Set<ViewData> removedViews = new HashSet<ViewData>(); Set<Integer> removedRows = new HashSet<Integer>(); for (INode child : selectedChildren) { ViewData view = getView(child); removedViews.add(view); removedRows.add(view.row); } // Sort them in descending order such that we can process each // deletion independently List<Integer> removed = new ArrayList<Integer>(removedRows); Collections.sort(removed, Collections.reverseOrder()); for (int removedRow : removed) { // Remove row. // First, adjust row count. // TODO: Don't do this if the row being deleted is outside // the declared row range! actualRowCount--; if (declaredRowCount != UNDEFINED) { declaredRowCount--; setGridAttribute(layout, ATTR_ROW_COUNT, declaredRowCount); } // Remove any elements that begin in the deleted rows... // If they have colspan > 1, then we must hardcode a new row number // instead. // For any other elements that overlap, we need to subtract from the span. for (ViewData view : mChildViews) { if (view.row == removedRow) { // We don't have to worry about a rowSpan > 1 here, because even // if it is, those rowspans are not used to assign default row/column // positions for other cells // TODO: Check this; it differs from the removeColumns logic! layout.removeChild(view.node); } else if (view.row > removedRow) { view.row--; if (getGridAttribute(view.node, ATTR_LAYOUT_ROW) != null) { setGridAttribute(view.node, ATTR_LAYOUT_ROW, view.row); } } else if (view.row < removedRow && view.row + view.rowSpan > removedRow) { // Subtract row span to skip this item view.rowSpan--; setRowSpanAttribute(view.node, view.rowSpan); } } } // Remove children from child list! if (removedViews.size() <= 2) { mChildViews.removeAll(removedViews); } else { List<ViewData> remaining = new ArrayList<ViewData>(mChildViews.size() - removedViews.size()); for (ViewData view : mChildViews) { if (!removedViews.contains(view)) { remaining.add(view); } } mChildViews = remaining; } } /** * Returns the row containing the given y line * * @param y the vertical position * @return the row containing the given line */ public int getRow(int y) { int row = Arrays.binarySearch(mTop, y); if (row == -1) { // Smaller than the first element; just use the first row return 0; } else if (row < 0) { row = -(row + 2); } return row; } /** * Returns the column containing the given x line * * @param x the horizontal position * @return the column containing the given line */ public int getColumn(int x) { int column = Arrays.binarySearch(mLeft, x); if (column == -1) { // Smaller than the first element; just use the first column return 0; } else if (column < 0) { column = -(column + 2); } return column; } /** * Returns the closest row to the given y line. This is * either the row containing the line, or the row below it. * * @param y the vertical position * @return the closest row */ public int getClosestRow(int y) { int row = Arrays.binarySearch(mTop, y); if (row == -1) { // Smaller than the first element; just use the first column return 0; } else if (row < 0) { row = -(row + 2); } if (getRowDistance(row, y) < getRowDistance(row + 1, y)) { return row; } else { return row + 1; } } /** * Returns the closest column to the given x line. This is * either the column containing the line, or the column following it. * * @param x the horizontal position * @return the closest column */ public int getClosestColumn(int x) { int column = Arrays.binarySearch(mLeft, x); if (column == -1) { // Smaller than the first element; just use the first column return 0; } else if (column < 0) { column = -(column + 2); } if (getColumnDistance(column, x) < getColumnDistance(column + 1, x)) { return column; } else { return column + 1; } } /** * Returns the distance between the given x position and the beginning of the given column * * @param column the column * @param x the x position * @return the distance between the two */ public int getColumnDistance(int column, int x) { return abs(getColumnX(column) - x); } /** * Returns the actual width of the given column. This returns the difference between * the rightmost edge of the views (not including spacers) and the left edge of the * column. * * @param column the column * @return the actual width of the non-spacer views in the column */ public int getColumnActualWidth(int column) { return getColumnMaxX(column) - getColumnX(column); } /** * Returns the distance between the given y position and the top of the given row * * @param row the row * @param y the y position * @return the distance between the two */ public int getRowDistance(int row, int y) { return abs(getRowY(row) - y); } /** * Returns the y position of the top of the given row * * @param row the target row * @return the y position of its top edge */ public int getRowY(int row) { return mTop[min(mTop.length - 1, max(0, row))]; } /** * Returns the bottom-most edge of any of the non-spacer children in the given row * * @param row the target row * @return the bottom-most edge of any of the non-spacer children in the row */ public int getRowMaxY(int row) { return mMaxBottom[min(mMaxBottom.length - 1, max(0, row))]; } /** * Returns the actual height of the given row. This returns the difference between * the bottom-most edge of the views (not including spacers) and the top edge of the * row. * * @param row the row * @return the actual height of the non-spacer views in the row */ public int getRowActualHeight(int row) { return getRowMaxY(row) - getRowY(row); } /** * Returns a list of all the nodes that intersects the rows in the range * {@code y1 <= y <= y2}. * * @param y1 the starting y, inclusive * @param y2 the ending y, inclusive * @return a list of nodes intersecting the given rows, never null but possibly empty */ public Collection<INode> getIntersectsRow(int y1, int y2) { List<INode> nodes = new ArrayList<INode>(); for (ViewData view : mChildViews) { if (!view.isSpacer()) { Rect bounds = view.node.getBounds(); if (bounds.y2() >= y1 && bounds.y <= y2) { nodes.add(view.node); } } } return nodes; } /** * Returns the height of the given row or rows (if the rowSpan is greater than 1) * * @param row the target row * @param rowSpan the row span * @return the height in pixels of the given rows */ public int getRowHeight(int row, int rowSpan) { return getRowY(row + rowSpan) - getRowY(row); } /** * Returns the x position of the left edge of the given column * * @param column the target column * @return the x position of its left edge */ public int getColumnX(int column) { return mLeft[min(mLeft.length - 1, max(0, column))]; } /** * Returns the rightmost edge of any of the non-spacer children in the given row * * @param column the target column * @return the rightmost edge of any of the non-spacer children in the column */ public int getColumnMaxX(int column) { return mMaxRight[min(mMaxRight.length - 1, max(0, column))]; } /** * Returns the width of the given column or columns (if the columnSpan is greater than 1) * * @param column the target column * @param columnSpan the column span * @return the width in pixels of the given columns */ public int getColumnWidth(int column, int columnSpan) { return getColumnX(column + columnSpan) - getColumnX(column); } /** * Returns the bounds of the cell at the given row and column position, with the given * row and column spans. * * @param row the target row * @param column the target column * @param rowSpan the row span * @param columnSpan the column span * @return the bounds, in pixels, of the given cell */ public Rect getCellBounds(int row, int column, int rowSpan, int columnSpan) { return new Rect(getColumnX(column), getRowY(row), getColumnWidth(column, columnSpan), getRowHeight(row, rowSpan)); } /** * Produces a display of view contents along with the pixel positions of each * row/column, like the following (used for diagnostics only) * * <pre> * |0 |49 |143 |192 |240 * 36| | |button2 | * 72| |radioButton1 |button2 | * 74|button1 |radioButton1 |button2 | * 108|button1 | |button2 | * 110| | |button2 | * 149| | | | * 320 * </pre> */ @Override public String toString() { // Dump out the view table int cellWidth = 25; List<List<List<ViewData>>> rowList = new ArrayList<List<List<ViewData>>>(mTop.length); for (int row = 0; row < mTop.length; row++) { List<List<ViewData>> columnList = new ArrayList<List<ViewData>>(mLeft.length); for (int col = 0; col < mLeft.length; col++) { columnList.add(new ArrayList<ViewData>(4)); } rowList.add(columnList); } for (ViewData view : mChildViews) { for (int i = 0; i < view.rowSpan; i++) { if (view.row + i > mTop.length) { // Guard against bogus span values break; } if (rowList.size() <= view.row + i) { break; } for (int j = 0; j < view.columnSpan; j++) { List<List<ViewData>> columnList = rowList.get(view.row + i); if (columnList.size() <= view.column + j) { break; } columnList.get(view.column + j).add(view); } } } StringWriter stringWriter = new StringWriter(); PrintWriter out = new PrintWriter(stringWriter); out.printf("%" + cellWidth + "s", ""); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ for (int col = 0; col < actualColumnCount + 1; col++) { out.printf("|%-" + (cellWidth - 1) + "d", mLeft[col]); //$NON-NLS-1$ //$NON-NLS-2$ } out.printf("\n"); //$NON-NLS-1$ for (int row = 0; row < actualRowCount + 1; row++) { out.printf("%" + cellWidth + "d", mTop[row]); //$NON-NLS-1$ //$NON-NLS-2$ if (row == actualRowCount) { break; } for (int col = 0; col < actualColumnCount; col++) { List<ViewData> views = rowList.get(row).get(col); StringBuilder sb = new StringBuilder(); for (ViewData view : views) { String id = view != null ? view.getId() : ""; //$NON-NLS-1$ if (id.startsWith(NEW_ID_PREFIX)) { id = id.substring(NEW_ID_PREFIX.length()); } if (id.length() > cellWidth - 2) { id = id.substring(0, cellWidth - 2); } if (sb.length() > 0) { sb.append(','); } sb.append(id); } String cellString = sb.toString(); if (cellString.contains(",") && cellString.length() > cellWidth - 2) { //$NON-NLS-1$ cellString = cellString.substring(0, cellWidth - 6) + "...,"; //$NON-NLS-1$ } out.printf("|%-" + (cellWidth - 2) + "s ", cellString); //$NON-NLS-1$ //$NON-NLS-2$ } out.printf("\n"); //$NON-NLS-1$ } out.flush(); return stringWriter.toString(); } /** * Split a cell into two or three columns. * * @param newColumn The column number to insert before * @param insertMarginColumn If false, then the cell at newColumn -1 is split with the * left part taking up exactly columnWidthDp dips. If true, then the column * is split twice; the left part is the implicit width of the column, the * new middle (margin) column is exactly the columnWidthDp size and the * right column is the remaining space of the old cell. * @param columnWidthDp The width of the column inserted before the new column (or if * insertMarginColumn is false, then the width of the margin column) * @param x the x coordinate of the new column */ public void splitColumn(int newColumn, boolean insertMarginColumn, int columnWidthDp, int x) { actualColumnCount++; // Insert a new column if (declaredColumnCount != UNDEFINED) { declaredColumnCount++; if (insertMarginColumn) { declaredColumnCount++; } setGridAttribute(layout, ATTR_COLUMN_COUNT, declaredColumnCount); } // Are we inserting a new last column in the grid? That requires some special handling... boolean isLastColumn = true; for (ViewData view : mChildViews) { if (view.column >= newColumn) { isLastColumn = false; break; } } // Hardcode the row numbers if the last column is a new column such that // they don't jump back to backfill the previous row's new last cell: // TODO: Only do this for horizontal layouts! if (isLastColumn) { for (ViewData view : mChildViews) { if (view.column == 0 && view.row > 0) { if (getGridAttribute(view.node, ATTR_LAYOUT_ROW) == null) { setGridAttribute(view.node, ATTR_LAYOUT_ROW, view.row); } } } } // Find the spacer which marks this column, and if found, mark it as a split ViewData prevColumnSpacer = null; for (ViewData view : mChildViews) { if (view.column == newColumn - 1 && view.isColumnSpacer()) { prevColumnSpacer = view; break; } } // Process all existing grid elements: // * Increase column numbers for all columns that have a hardcoded column number // greater than the new column // * Set an explicit column=0 where needed (TODO: Implement this) // * Increase the columnSpan for all columns that overlap the newly inserted column edge // * Split the spacer which defined the size of this column into two // (and if not found, create a new spacer) // for (ViewData view : mChildViews) { if (view == prevColumnSpacer) { continue; } INode node = view.node; int column = view.column; if (column > newColumn || (column == newColumn && view.node.getBounds().x2() > x)) { // ALWAYS set the column, because // (1) if it has been set, it needs to be corrected // (2) if it has not been set, it needs to be set to cause this column // to skip over the new column (there may be no views for the new // column on this row). // TODO: Enhance this such that we only set the column to a skip number // where necessary, e.g. only on the FIRST view on this row following the // skipped column! //if (getGridAttribute(node, ATTR_LAYOUT_COLUMN) != null) { view.column += insertMarginColumn ? 2 : 1; setGridAttribute(node, ATTR_LAYOUT_COLUMN, view.column); //} } else if (!view.isSpacer()) { // Adjust the column span? We must increase it if // (1) the new column is inside the range [column, column + columnSpan] // (2) the new column is within the last cell in the column span, // and the exact X location of the split is within the horizontal // *bounds* of this node (provided it has gravity=left) // (3) the new column is within the last cell and the cell has gravity // right or gravity center int endColumn = column + view.columnSpan; if (endColumn > newColumn || endColumn == newColumn && (view.node.getBounds().x2() > x || GravityHelper.isConstrainedHorizontally(view.gravity) && !GravityHelper.isLeftAligned(view.gravity))) { // This cell spans the new insert position, so increment the column span view.columnSpan += insertMarginColumn ? 2 : 1; setColumnSpanAttribute(node, view.columnSpan); } } } // Insert new spacer: if (prevColumnSpacer != null) { int px = getColumnWidth(newColumn - 1, 1); if (insertMarginColumn || columnWidthDp == 0) { px -= getColumnActualWidth(newColumn - 1); } int dp = mRulesEngine.pxToDp(px); int remaining = dp - columnWidthDp; if (remaining > 0) { prevColumnSpacer.node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, String.format(VALUE_N_DP, remaining)); prevColumnSpacer.column = insertMarginColumn ? newColumn + 1 : newColumn; setGridAttribute(prevColumnSpacer.node, ATTR_LAYOUT_COLUMN, prevColumnSpacer.column); } } if (columnWidthDp > 0) { int index = prevColumnSpacer != null ? prevColumnSpacer.index : -1; addSpacer(layout, index, 0, insertMarginColumn ? newColumn : newColumn - 1, columnWidthDp, SPACER_SIZE_DP); } } /** * Split a cell into two or three rows. * * @param newRow The row number to insert before * @param insertMarginRow If false, then the cell at newRow -1 is split with the above * part taking up exactly rowHeightDp dips. If true, then the row is split * twice; the top part is the implicit height of the row, the new middle * (margin) row is exactly the rowHeightDp size and the bottom column is * the remaining space of the old cell. * @param rowHeightDp The height of the row inserted before the new row (or if * insertMarginRow is false, then the height of the margin row) * @param y the y coordinate of the new row */ public void splitRow(int newRow, boolean insertMarginRow, int rowHeightDp, int y) { actualRowCount++; // Insert a new row if (declaredRowCount != UNDEFINED) { declaredRowCount++; if (insertMarginRow) { declaredRowCount++; } setGridAttribute(layout, ATTR_ROW_COUNT, declaredRowCount); } // Find the spacer which marks this row, and if found, mark it as a split ViewData prevRowSpacer = null; for (ViewData view : mChildViews) { if (view.row == newRow - 1 && view.isRowSpacer()) { prevRowSpacer = view; break; } } // Se splitColumn() for details for (ViewData view : mChildViews) { if (view == prevRowSpacer) { continue; } INode node = view.node; int row = view.row; if (row > newRow || (row == newRow && view.node.getBounds().y2() > y)) { //if (getGridAttribute(node, ATTR_LAYOUT_ROW) != null) { view.row += insertMarginRow ? 2 : 1; setGridAttribute(node, ATTR_LAYOUT_ROW, view.row); //} } else if (!view.isSpacer()) { int endRow = row + view.rowSpan; if (endRow > newRow || endRow == newRow && (view.node.getBounds().y2() > y || GravityHelper.isConstrainedVertically(view.gravity) && !GravityHelper.isTopAligned(view.gravity))) { // This cell spans the new insert position, so increment the row span view.rowSpan += insertMarginRow ? 2 : 1; setRowSpanAttribute(node, view.rowSpan); } } } // Insert new spacer: if (prevRowSpacer != null) { int px = getRowHeight(newRow - 1, 1); if (insertMarginRow || rowHeightDp == 0) { px -= getRowActualHeight(newRow - 1); } int dp = mRulesEngine.pxToDp(px); int remaining = dp - rowHeightDp; if (remaining > 0) { prevRowSpacer.node.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, String.format(VALUE_N_DP, remaining)); prevRowSpacer.row = insertMarginRow ? newRow + 1 : newRow; setGridAttribute(prevRowSpacer.node, ATTR_LAYOUT_ROW, prevRowSpacer.row); } } if (rowHeightDp > 0) { int index = prevRowSpacer != null ? prevRowSpacer.index : -1; addSpacer(layout, index, insertMarginRow ? newRow : newRow - 1, 0, SPACER_SIZE_DP, rowHeightDp); } } /** * Data about a view in a table; this is not the same as a cell because multiple views * can share a single cell, and a view can span many cells. */ class ViewData { public final INode node; public final int index; public int row; public int column; public int rowSpan; public int columnSpan; public int gravity; ViewData(INode n, int index) { node = n; this.index = index; column = getGridAttribute(n, ATTR_LAYOUT_COLUMN, UNDEFINED); columnSpan = getGridAttribute(n, ATTR_LAYOUT_COLUMN_SPAN, 1); row = getGridAttribute(n, ATTR_LAYOUT_ROW, UNDEFINED); rowSpan = getGridAttribute(n, ATTR_LAYOUT_ROW_SPAN, 1); gravity = GravityHelper.getGravity(getGridAttribute(n, ATTR_LAYOUT_GRAVITY), 0); } /** Applies the column and row fields into the XML model */ void applyPositionAttributes() { setGridAttribute(node, ATTR_LAYOUT_COLUMN, column); setGridAttribute(node, ATTR_LAYOUT_ROW, row); } /** Returns the id of this node, or makes one up for display purposes */ String getId() { String id = node.getStringAttr(ANDROID_URI, ATTR_ID); if (id == null) { id = "<unknownid>"; //$NON-NLS-1$ String fqn = node.getFqcn(); fqn = fqn.substring(fqn.lastIndexOf('.') + 1); id = fqn + "-" + Integer.toString(System.identityHashCode(node)).substring(0, 3); } return id; } /** Returns true if this {@link ViewData} represents a spacer */ boolean isSpacer() { return isSpace(node.getFqcn()); } /** * Returns true if this {@link ViewData} represents a column spacer */ boolean isColumnSpacer() { return isSpacer() && // Any spacer not found in column 0 is a column spacer since we // place all horizontal spacers in column 0 ((column > 0) // TODO: Find a cleaner way. Maybe set ids on the elements in (0,0) and // for column distinguish by id. Or at least only do this for column 0! || !SPACER_SIZE.equals(node.getStringAttr(ANDROID_URI, ATTR_LAYOUT_WIDTH))); } /** * Returns true if this {@link ViewData} represents a row spacer */ boolean isRowSpacer() { return isSpacer() && // Any spacer not found in row 0 is a row spacer since we // place all vertical spacers in row 0 ((row > 0) // TODO: Find a cleaner way. Maybe set ids on the elements in (0,0) and // for column distinguish by id. Or at least only do this for column 0! || !SPACER_SIZE.equals(node.getStringAttr(ANDROID_URI, ATTR_LAYOUT_HEIGHT))); } } /** * Sets the column span of the given node to the given value (or if the value is 1, * removes it) * * @param node the target node * @param span the new column span */ public void setColumnSpanAttribute(INode node, int span) { setGridAttribute(node, ATTR_LAYOUT_COLUMN_SPAN, span > 1 ? Integer.toString(span) : null); } /** * Sets the row span of the given node to the given value (or if the value is 1, * removes it) * * @param node the target node * @param span the new row span */ public void setRowSpanAttribute(INode node, int span) { setGridAttribute(node, ATTR_LAYOUT_ROW_SPAN, span > 1 ? Integer.toString(span) : null); } /** Returns the index of the given target node in the given child node array */ static int getChildIndex(INode[] children, INode target) { int index = 0; for (INode child : children) { if (child == target) { return index; } index++; } return -1; } /** * Update the model to account for the given nodes getting deleted. The nodes * are not actually deleted by this method; that is assumed to be performed by the * caller. Instead this method performs whatever model updates are necessary to * preserve the grid structure. * * @param nodes the nodes to be deleted */ public void onDeleted(@NonNull List<INode> nodes) { if (nodes.size() == 0) { return; } // Attempt to clean up spacer objects for any newly-empty rows or columns // as the result of this deletion Set<INode> deleted = new HashSet<INode>(); for (INode child : nodes) { // We don't care about deletion of spacers String fqcn = child.getFqcn(); if (fqcn.equals(FQCN_SPACE) || fqcn.equals(FQCN_SPACE_V7)) { continue; } deleted.add(child); } Set<Integer> usedColumns = new HashSet<Integer>(actualColumnCount); Set<Integer> usedRows = new HashSet<Integer>(actualRowCount); Multimap<Integer, ViewData> columnSpacers = ArrayListMultimap.create(actualColumnCount, 2); Multimap<Integer, ViewData> rowSpacers = ArrayListMultimap.create(actualRowCount, 2); Set<ViewData> removedViews = new HashSet<ViewData>(); for (ViewData view : mChildViews) { if (deleted.contains(view.node)) { removedViews.add(view); } else if (view.isColumnSpacer()) { columnSpacers.put(view.column, view); } else if (view.isRowSpacer()) { rowSpacers.put(view.row, view); } else { usedColumns.add(Integer.valueOf(view.column)); usedRows.add(Integer.valueOf(view.row)); } } if (usedColumns.size() == 0 || usedRows.size() == 0) { // No more views - just remove all the spacers for (ViewData spacer : columnSpacers.values()) { layout.removeChild(spacer.node); } for (ViewData spacer : rowSpacers.values()) { layout.removeChild(spacer.node); } mChildViews.clear(); actualColumnCount = 0; declaredColumnCount = 2; actualRowCount = 0; declaredRowCount = UNDEFINED; setGridAttribute(layout, ATTR_COLUMN_COUNT, 2); return; } // Determine columns to introduce spacers into: // This is tricky; I should NOT combine spacers if there are cells tied to // individual ones // TODO: Invalidate column sizes too! Otherwise repeated updates might get confused! // Similarly, inserts need to do the same! // Produce map of old column numbers to new column numbers // Collapse regions of consecutive space and non-space ranges together int[] columnMap = new int[actualColumnCount + 1]; // +1: Easily handle columnSpans as well int newColumn = 0; boolean prevUsed = usedColumns.contains(0); for (int column = 1; column < actualColumnCount; column++) { boolean used = usedColumns.contains(column); if (used || prevUsed != used) { newColumn++; prevUsed = used; } columnMap[column] = newColumn; } newColumn++; columnMap[actualColumnCount] = newColumn; assert columnMap[0] == 0; int[] rowMap = new int[actualRowCount + 1]; // +1: Easily handle rowSpans as well int newRow = 0; prevUsed = usedRows.contains(0); for (int row = 1; row < actualRowCount; row++) { boolean used = usedRows.contains(row); if (used || prevUsed != used) { newRow++; prevUsed = used; } rowMap[row] = newRow; } newRow++; rowMap[actualRowCount] = newRow; assert rowMap[0] == 0; // Adjust column and row numbers to account for deletions: for a given cell, if it // is to the right of a deleted column, reduce its column number, and if it only // spans across the deleted column, reduce its column span. for (ViewData view : mChildViews) { if (removedViews.contains(view)) { continue; } int newColumnStart = columnMap[Math.min(columnMap.length - 1, view.column)]; // Gracefully handle rogue/invalid columnSpans in the XML int newColumnEnd = columnMap[Math.min(columnMap.length - 1, view.column + view.columnSpan)]; if (newColumnStart != view.column) { view.column = newColumnStart; setGridAttribute(view.node, ATTR_LAYOUT_COLUMN, view.column); } int columnSpan = newColumnEnd - newColumnStart; if (columnSpan != view.columnSpan) { if (columnSpan >= 1) { view.columnSpan = columnSpan; setColumnSpanAttribute(view.node, view.columnSpan); } // else: merging spacing columns together } int newRowStart = rowMap[Math.min(rowMap.length - 1, view.row)]; int newRowEnd = rowMap[Math.min(rowMap.length - 1, view.row + view.rowSpan)]; if (newRowStart != view.row) { view.row = newRowStart; setGridAttribute(view.node, ATTR_LAYOUT_ROW, view.row); } int rowSpan = newRowEnd - newRowStart; if (rowSpan != view.rowSpan) { if (rowSpan >= 1) { view.rowSpan = rowSpan; setRowSpanAttribute(view.node, view.rowSpan); } // else: merging spacing rows together } } // Merge spacers (and add spacers for newly empty columns) int start = 0; while (start < actualColumnCount) { // Find next unused span while (start < actualColumnCount && usedColumns.contains(start)) { start++; } if (start == actualColumnCount) { break; } assert !usedColumns.contains(start); // Find the next span of unused columns and produce a SINGLE // spacer for that range (unless it's a zero-sized columns) int end = start + 1; for (; end < actualColumnCount; end++) { if (usedColumns.contains(end)) { break; } } // Add up column sizes int width = getColumnWidth(start, end - start); // Find all spacers: the first one found should be moved to the start column // and assigned to the full height of the columns, and // the column count reduced by the corresponding amount // TODO: if width = 0, fully remove boolean isFirstSpacer = true; for (int column = start; column < end; column++) { Collection<ViewData> spacers = columnSpacers.get(column); if (spacers != null && !spacers.isEmpty()) { // Avoid ConcurrentModificationException since we're inserting into the // map within this loop (always at a different index, but the map doesn't // know that) spacers = new ArrayList<ViewData>(spacers); for (ViewData spacer : spacers) { if (isFirstSpacer) { isFirstSpacer = false; spacer.column = columnMap[start]; setGridAttribute(spacer.node, ATTR_LAYOUT_COLUMN, spacer.column); if (end - start > 1) { // Compute a merged width for all the spacers (not needed if // there's just one spacer; it should already have the correct width) int columnWidthDp = mRulesEngine.pxToDp(width); spacer.node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, String.format(VALUE_N_DP, columnWidthDp)); } columnSpacers.put(start, spacer); } else { removedViews.add(spacer); // Mark for model removal layout.removeChild(spacer.node); } } } } if (isFirstSpacer) { // No spacer: create one int columnWidthDp = mRulesEngine.pxToDp(width); addSpacer(layout, -1, UNDEFINED, columnMap[start], columnWidthDp, DEFAULT_CELL_HEIGHT); } start = end; } actualColumnCount = newColumn; //if (usedColumns.contains(newColumn)) { // // TODO: This may be totally wrong for right aligned content! // actualColumnCount++; //} // Merge spacers for rows start = 0; while (start < actualRowCount) { // Find next unused span while (start < actualRowCount && usedRows.contains(start)) { start++; } if (start == actualRowCount) { break; } assert !usedRows.contains(start); // Find the next span of unused rows and produce a SINGLE // spacer for that range (unless it's a zero-sized rows) int end = start + 1; for (; end < actualRowCount; end++) { if (usedRows.contains(end)) { break; } } // Add up row sizes int height = getRowHeight(start, end - start); // Find all spacers: the first one found should be moved to the start row // and assigned to the full height of the rows, and // the row count reduced by the corresponding amount // TODO: if width = 0, fully remove boolean isFirstSpacer = true; for (int row = start; row < end; row++) { Collection<ViewData> spacers = rowSpacers.get(row); if (spacers != null && !spacers.isEmpty()) { // Avoid ConcurrentModificationException since we're inserting into the // map within this loop (always at a different index, but the map doesn't // know that) spacers = new ArrayList<ViewData>(spacers); for (ViewData spacer : spacers) { if (isFirstSpacer) { isFirstSpacer = false; spacer.row = rowMap[start]; setGridAttribute(spacer.node, ATTR_LAYOUT_ROW, spacer.row); if (end - start > 1) { // Compute a merged width for all the spacers (not needed if // there's just one spacer; it should already have the correct height) int rowHeightDp = mRulesEngine.pxToDp(height); spacer.node.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, String.format(VALUE_N_DP, rowHeightDp)); } rowSpacers.put(start, spacer); } else { removedViews.add(spacer); // Mark for model removal layout.removeChild(spacer.node); } } } } if (isFirstSpacer) { // No spacer: create one int rowWidthDp = mRulesEngine.pxToDp(height); addSpacer(layout, -1, rowMap[start], UNDEFINED, DEFAULT_CELL_WIDTH, rowWidthDp); } start = end; } actualRowCount = newRow; // if (usedRows.contains(newRow)) { // actualRowCount++; // } // Update the model: remove removed children from the view data list if (removedViews.size() <= 2) { mChildViews.removeAll(removedViews); } else { List<ViewData> remaining = new ArrayList<ViewData>(mChildViews.size() - removedViews.size()); for (ViewData view : mChildViews) { if (!removedViews.contains(view)) { remaining.add(view); } } mChildViews = remaining; } // Update the final column and row declared attributes if (declaredColumnCount != UNDEFINED) { declaredColumnCount = actualColumnCount; setGridAttribute(layout, ATTR_COLUMN_COUNT, actualColumnCount); } if (declaredRowCount != UNDEFINED) { declaredRowCount = actualRowCount; setGridAttribute(layout, ATTR_ROW_COUNT, actualRowCount); } } /** * Adds a spacer to the given parent, at the given index. * * @param parent the GridLayout * @param index the index to insert the spacer at, or -1 to append * @param row the row to add the spacer to (or {@link #UNDEFINED} to not set a row yet * @param column the column to add the spacer to (or {@link #UNDEFINED} to not set a * column yet * @param widthDp the width in device independent pixels to assign to the spacer * @param heightDp the height in device independent pixels to assign to the spacer * @return the newly added spacer */ ViewData addSpacer(INode parent, int index, int row, int column, int widthDp, int heightDp) { INode spacer; String tag = FQCN_SPACE; String gridLayout = parent.getFqcn(); if (!gridLayout.equals(GRID_LAYOUT) && gridLayout.length() > GRID_LAYOUT.length()) { String pkg = gridLayout.substring(0, gridLayout.length() - GRID_LAYOUT.length()); tag = pkg + SPACE; } if (index != -1) { spacer = parent.insertChildAt(tag, index); } else { spacer = parent.appendChild(tag); } ViewData view = new ViewData(spacer, index != -1 ? index : mChildViews.size()); mChildViews.add(view); if (row != UNDEFINED) { view.row = row; setGridAttribute(spacer, ATTR_LAYOUT_ROW, row); } if (column != UNDEFINED) { view.column = column; setGridAttribute(spacer, ATTR_LAYOUT_COLUMN, column); } if (widthDp > 0) { spacer.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, String.format(VALUE_N_DP, widthDp)); } if (heightDp > 0) { spacer.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, String.format(VALUE_N_DP, heightDp)); } // Temporary hack if (GridLayoutRule.sDebugGridLayout) { //String id = NEW_ID_PREFIX + "s"; //if (row == 0) { // id += "c"; //} //if (column == 0) { // id += "r"; //} //if (row > 0) { // id += Integer.toString(row); //} //if (column > 0) { // id += Integer.toString(column); //} String id = NEW_ID_PREFIX + "spacer_" //$NON-NLS-1$ + Integer.toString(System.identityHashCode(spacer)).substring(0, 3); spacer.setAttribute(ANDROID_URI, ATTR_ID, id); } return view; } /** * Returns the string value of the given attribute, or null if it does not * exist. This only works for attributes that are GridLayout specific, such * as columnCount, layout_column, layout_row_span, etc. * * @param node the target node * @param name the attribute name (which must be in the android: namespace) * @return the attribute value or null */ public String getGridAttribute(INode node, String name) { return node.getStringAttr(getNamespace(), name); } /** * Returns the integer value of the given attribute, or the given defaultValue if the * attribute was not set. This only works for attributes that are GridLayout specific, * such as columnCount, layout_column, layout_row_span, etc. * * @param node the target node * @param attribute the attribute name (which must be in the android: namespace) * @param defaultValue the default value to use if the value is not set * @return the attribute integer value */ private int getGridAttribute(INode node, String attribute, int defaultValue) { String valueString = node.getStringAttr(getNamespace(), attribute); if (valueString != null) { try { return Integer.decode(valueString); } catch (NumberFormatException nufe) { // Ignore - error in user's XML } } return defaultValue; } /** * Returns the number of children views in the GridLayout * * @return the number of children views in the GridLayout */ public int getViewCount() { return mChildViews.size(); } /** * Returns true if the given class name represents a spacer * * @param fqcn the fully qualified class name * @return true if this is a spacer */ public static boolean isSpace(String fqcn) { return FQCN_SPACE.equals(fqcn) || FQCN_SPACE_V7.equals(fqcn); } }