1 /* 2 * Copyright (C) 2011 The Android Open Source Project 3 * 4 * Licensed under the Eclipse Public License, Version 1.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.eclipse.org/org/documents/epl-v10.php 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.ide.common.layout; 18 19 import static com.android.ide.common.layout.LayoutConstants.ANDROID_URI; 20 import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_GRAVITY; 21 import static com.android.ide.common.layout.LayoutConstants.ATTR_ORIENTATION; 22 import static com.android.ide.common.layout.LayoutConstants.FQCN_SPACE; 23 import static com.android.ide.common.layout.LayoutConstants.GRAVITY_VALUE_FILL; 24 import static com.android.ide.common.layout.LayoutConstants.GRAVITY_VALUE_FILL_HORIZONTAL; 25 import static com.android.ide.common.layout.LayoutConstants.GRAVITY_VALUE_FILL_VERTICAL; 26 import static com.android.ide.common.layout.LayoutConstants.GRAVITY_VALUE_LEFT; 27 import static com.android.ide.common.layout.LayoutConstants.VALUE_HORIZONTAL; 28 import static com.android.ide.common.layout.LayoutConstants.VALUE_TRUE; 29 30 import com.android.ide.common.api.DrawingStyle; 31 import com.android.ide.common.api.DropFeedback; 32 import com.android.ide.common.api.IDragElement; 33 import com.android.ide.common.api.IFeedbackPainter; 34 import com.android.ide.common.api.IGraphics; 35 import com.android.ide.common.api.IMenuCallback; 36 import com.android.ide.common.api.INode; 37 import com.android.ide.common.api.INodeHandler; 38 import com.android.ide.common.api.IViewMetadata; 39 import com.android.ide.common.api.IViewMetadata.FillPreference; 40 import com.android.ide.common.api.IViewRule; 41 import com.android.ide.common.api.InsertType; 42 import com.android.ide.common.api.Point; 43 import com.android.ide.common.api.Rect; 44 import com.android.ide.common.api.RuleAction; 45 import com.android.ide.common.api.RuleAction.Choices; 46 import com.android.ide.common.api.SegmentType; 47 import com.android.ide.common.layout.grid.GridDropHandler; 48 import com.android.ide.common.layout.grid.GridLayoutPainter; 49 import com.android.ide.common.layout.grid.GridModel; 50 import com.android.util.Pair; 51 52 import java.net.URL; 53 import java.util.Arrays; 54 import java.util.Collections; 55 import java.util.List; 56 import java.util.Map; 57 58 /** 59 * An {@link IViewRule} for android.widget.GridLayout which provides designtime 60 * interaction with GridLayouts. 61 * <p> 62 * TODO: 63 * <ul> 64 * <li>Handle multi-drag: preserving relative positions and alignments among dragged 65 * views. 66 * <li>Handle GridLayouts that have been configured in a vertical orientation. 67 * <li>Handle free-form editing GridLayouts that have been manually edited rather than 68 * built up using free-form editing (e.g. they might not follow the same spacing 69 * convention, might use weights etc) 70 * <li>Avoid setting row and column numbers on the actual elements if they can be skipped 71 * to make the XML leaner. 72 * </ul> 73 */ 74 public class GridLayoutRule extends BaseLayoutRule { 75 /** 76 * The size of the visual regular grid that we snap to (if {@link #sSnapToGrid} is set 77 */ 78 public static final int GRID_SIZE = 16; 79 80 /** Standard gap between views */ 81 public static final int SHORT_GAP_DP = 16; 82 83 /** 84 * The preferred margin size, in pixels 85 */ 86 public static final int MARGIN_SIZE = 32; 87 88 /** 89 * Size in screen pixels in the IDE of the gutter shown for new rows and columns (in 90 * grid mode) 91 */ 92 private static final int NEW_CELL_WIDTH = 10; 93 94 /** 95 * Maximum size of a widget relative to a cell which is allowed to fit into a cell 96 * (and thereby enlarge it) before it is spread with row or column spans. 97 */ 98 public static final double MAX_CELL_DIFFERENCE = 1.2; 99 100 /** Whether debugging diagnostics is available in the toolbar */ 101 private static final boolean CAN_DEBUG = 102 VALUE_TRUE.equals(System.getenv("ADT_DEBUG_GRIDLAYOUT")); //$NON-NLS-1$ 103 104 private static final String ACTION_ADD_ROW = "_addrow"; //$NON-NLS-1$ 105 private static final String ACTION_REMOVE_ROW = "_removerow"; //$NON-NLS-1$ 106 private static final String ACTION_ADD_COL = "_addcol"; //$NON-NLS-1$ 107 private static final String ACTION_REMOVE_COL = "_removecol"; //$NON-NLS-1$ 108 private static final String ACTION_ORIENTATION = "_orientation"; //$NON-NLS-1$ 109 private static final String ACTION_SHOW_GRID = "_grid"; //$NON-NLS-1$ 110 private static final String ACTION_SNAP = "_snap"; //$NON-NLS-1$ 111 private static final String ACTION_DEBUG = "_debug"; //$NON-NLS-1$ 112 113 private static final URL ICON_HORIZONTAL = GridLayoutRule.class.getResource("hlinear.png"); //$NON-NLS-1$ 114 private static final URL ICON_VERTICAL = GridLayoutRule.class.getResource("vlinear.png"); //$NON-NLS-1$ 115 private static final URL ICON_ADD_ROW = GridLayoutRule.class.getResource("addrow.png"); //$NON-NLS-1$ 116 private static final URL ICON_REMOVE_ROW = GridLayoutRule.class.getResource("removerow.png"); //$NON-NLS-1$ 117 private static final URL ICON_ADD_COL = GridLayoutRule.class.getResource("addcol.png"); //$NON-NLS-1$ 118 private static final URL ICON_REMOVE_COL = GridLayoutRule.class.getResource("removecol.png"); //$NON-NLS-1$ 119 private static final URL ICON_SHOW_GRID = GridLayoutRule.class.getResource("showgrid.png"); //$NON-NLS-1$ 120 private static final URL ICON_SNAP = GridLayoutRule.class.getResource("snap.png"); //$NON-NLS-1$ 121 122 /** 123 * Whether the IDE should show diagnostics for debugging the grid layout - including 124 * spacers visibly in the outline, showing row and column numbers, and so on 125 */ 126 public static boolean sDebugGridLayout = false; 127 128 /** Whether the structure (grid model) should be displayed persistently to the user */ 129 public static boolean sShowStructure = false; 130 131 /** Whether the drop positions should snap to a regular grid */ 132 public static boolean sSnapToGrid = false; 133 134 /** 135 * Whether the grid is edited in "grid mode" where the operations are row/column based 136 * rather than free-form 137 */ 138 public static boolean sGridMode = false; 139 140 /** Constructs a new {@link GridLayoutRule} */ GridLayoutRule()141 public GridLayoutRule() { 142 } 143 144 @Override addLayoutActions(List<RuleAction> actions, final INode parentNode, final List<? extends INode> children)145 public void addLayoutActions(List<RuleAction> actions, final INode parentNode, 146 final List<? extends INode> children) { 147 super.addLayoutActions(actions, parentNode, children); 148 149 Choices orientationAction = RuleAction.createChoices( 150 ACTION_ORIENTATION, 151 "Orientation", //$NON-NLS-1$ 152 new PropertyCallback(Collections.singletonList(parentNode), 153 "Change LinearLayout Orientation", ANDROID_URI, ATTR_ORIENTATION), Arrays 154 .<String> asList("Set Horizontal Orientation", "Set Vertical Orientation"), 155 Arrays.<URL> asList(ICON_HORIZONTAL, ICON_VERTICAL), Arrays.<String> asList( 156 "horizontal", "vertical"), getCurrentOrientation(parentNode), 157 null /* icon */, -10, false); 158 orientationAction.setRadio(true); 159 actions.add(orientationAction); 160 161 // Gravity and margins 162 if (children != null && children.size() > 0) { 163 actions.add(RuleAction.createSeparator(35)); 164 actions.add(createMarginAction(parentNode, children)); 165 actions.add(createGravityAction(children, ATTR_LAYOUT_GRAVITY)); 166 } 167 168 IMenuCallback actionCallback = new IMenuCallback() { 169 public void action(final RuleAction action, List<? extends INode> selectedNodes, 170 final String valueId, final Boolean newValue) { 171 parentNode.editXml("Add/Remove Row/Column", new INodeHandler() { 172 public void handle(INode n) { 173 String id = action.getId(); 174 if (id.equals(ACTION_SHOW_GRID)) { 175 sShowStructure = !sShowStructure; 176 // HACK: ToggleButton controls two flags for now - show grid and 177 // grid mode (handling drags in a grid mode) 178 sGridMode = !sGridMode; 179 180 mRulesEngine.redraw(); 181 return; 182 } else if (id.equals(ACTION_SNAP)) { 183 sSnapToGrid = !sSnapToGrid; 184 mRulesEngine.redraw(); 185 return; 186 } else if (id.equals(ACTION_DEBUG)) { 187 sDebugGridLayout = !sDebugGridLayout; 188 mRulesEngine.layout(); 189 return; 190 } 191 192 GridModel grid = new GridModel(mRulesEngine, parentNode, null); 193 if (id.equals(ACTION_ADD_ROW)) { 194 grid.addRow(children); 195 } else if (id.equals(ACTION_REMOVE_ROW)) { 196 grid.removeRows(children); 197 } else if (id.equals(ACTION_ADD_COL)) { 198 grid.addColumn(children); 199 } else if (id.equals(ACTION_REMOVE_COL)) { 200 grid.removeColumns(children); 201 } 202 } 203 204 }); 205 } 206 }; 207 208 // Add Row and Add Column 209 actions.add(RuleAction.createSeparator(150)); 210 actions.add(RuleAction.createAction(ACTION_ADD_COL, "Add Column", actionCallback, 211 ICON_ADD_COL, 160, false /* supportsMultipleNodes */)); 212 actions.add(RuleAction.createAction(ACTION_ADD_ROW, "Add Row", actionCallback, 213 ICON_ADD_ROW, 165, false)); 214 215 // Remove Row and Remove Column (if something is selected) 216 if (children != null && children.size() > 0) { 217 // TODO: Add "Merge Columns" and "Merge Rows" ? 218 219 actions.add(RuleAction.createAction(ACTION_REMOVE_COL, "Remove Column", 220 actionCallback, ICON_REMOVE_COL, 170, false)); 221 actions.add(RuleAction.createAction(ACTION_REMOVE_ROW, "Remove Row", 222 actionCallback, ICON_REMOVE_ROW, 175, false)); 223 } 224 225 actions.add(RuleAction.createSeparator(185)); 226 227 actions.add(RuleAction.createToggle(ACTION_SNAP, "Snap to Grid", 228 sSnapToGrid, actionCallback, ICON_SNAP, 190, false)); 229 230 actions.add(RuleAction.createToggle(ACTION_SHOW_GRID, "Show Structure", 231 sShowStructure, actionCallback, ICON_SHOW_GRID, 200, false)); 232 233 // Temporary: Diagnostics for GridLayout 234 if (CAN_DEBUG) { 235 actions.add(RuleAction.createToggle(ACTION_DEBUG, "Debug", 236 sDebugGridLayout, actionCallback, null, 210, false)); 237 } 238 } 239 240 /** 241 * Returns the orientation attribute value currently used by the node (even if not 242 * defined, in which case the default horizontal value is returned) 243 */ getCurrentOrientation(final INode node)244 private static String getCurrentOrientation(final INode node) { 245 String orientation = node.getStringAttr(ANDROID_URI, ATTR_ORIENTATION); 246 if (orientation == null || orientation.length() == 0) { 247 orientation = VALUE_HORIZONTAL; 248 } 249 return orientation; 250 } 251 252 @Override onDropEnter(INode targetNode, Object targetView, IDragElement[] elements)253 public DropFeedback onDropEnter(INode targetNode, Object targetView, IDragElement[] elements) { 254 GridDropHandler userData = new GridDropHandler(this, targetNode, targetView); 255 IFeedbackPainter painter = GridLayoutPainter.createDropFeedbackPainter(this, elements); 256 return new DropFeedback(userData, painter); 257 } 258 259 @Override onDropMove(INode targetNode, IDragElement[] elements, DropFeedback feedback, Point p)260 public DropFeedback onDropMove(INode targetNode, IDragElement[] elements, 261 DropFeedback feedback, Point p) { 262 feedback.requestPaint = true; 263 264 GridDropHandler handler = (GridDropHandler) feedback.userData; 265 handler.computeMatches(feedback, p); 266 267 return feedback; 268 } 269 270 @Override onDropped(final INode targetNode, final IDragElement[] elements, DropFeedback feedback, Point p)271 public void onDropped(final INode targetNode, final IDragElement[] elements, 272 DropFeedback feedback, Point p) { 273 Rect b = targetNode.getBounds(); 274 if (!b.isValid()) { 275 return; 276 } 277 278 GridDropHandler dropHandler = (GridDropHandler) feedback.userData; 279 if (dropHandler.getRowMatch() == null || dropHandler.getColumnMatch() == null) { 280 return; 281 } 282 283 // Collect IDs from dropped elements and remap them to new IDs 284 // if this is a copy or from a different canvas. 285 Map<String, Pair<String, String>> idMap = getDropIdMap(targetNode, elements, 286 feedback.isCopy || !feedback.sameCanvas); 287 288 for (IDragElement element : elements) { 289 INode newChild; 290 if (!sGridMode) { 291 newChild = dropHandler.handleFreeFormDrop(targetNode, element); 292 } else { 293 newChild = dropHandler.handleGridModeDrop(targetNode, element); 294 } 295 296 // Copy all the attributes, modifying them as needed. 297 addAttributes(newChild, element, idMap, DEFAULT_ATTR_FILTER); 298 299 addInnerElements(newChild, element, idMap); 300 } 301 } 302 303 @Override onChildInserted(INode node, INode parent, InsertType insertType)304 public void onChildInserted(INode node, INode parent, InsertType insertType) { 305 if (insertType == InsertType.MOVE_WITHIN) { 306 // Don't adjust widths/heights/weights when just moving within a single layout 307 return; 308 } 309 310 // Attempt to set "fill" properties on newly added views such that for example 311 // a text field will stretch horizontally. 312 String fqcn = node.getFqcn(); 313 IViewMetadata metadata = mRulesEngine.getMetadata(fqcn); 314 if (metadata == null) { 315 return; 316 } 317 FillPreference fill = metadata.getFillPreference(); 318 String gravity = computeDefaultGravity(fill); 319 if (gravity != null) { 320 node.setAttribute(ANDROID_URI, ATTR_LAYOUT_GRAVITY, gravity); 321 } 322 } 323 324 /** 325 * Computes the default gravity to be used for a widget of the given fill 326 * preference when added to a grid layout 327 * 328 * @param fill the fill preference for the widget 329 * @return the gravity value, or null, to be set on the widget 330 */ computeDefaultGravity(FillPreference fill)331 public static String computeDefaultGravity(FillPreference fill) { 332 String horizontal = GRAVITY_VALUE_LEFT; 333 String vertical = null; 334 if (fill.fillHorizontally(true /*verticalContext*/)) { 335 horizontal = GRAVITY_VALUE_FILL_HORIZONTAL; 336 } 337 if (fill.fillVertically(true /*verticalContext*/)) { 338 vertical = GRAVITY_VALUE_FILL_VERTICAL; 339 } 340 String gravity; 341 if (horizontal == GRAVITY_VALUE_FILL_HORIZONTAL 342 && vertical == GRAVITY_VALUE_FILL_VERTICAL) { 343 gravity = GRAVITY_VALUE_FILL; 344 } else if (vertical != null) { 345 gravity = horizontal + '|' + vertical; 346 } else { 347 gravity = horizontal; 348 } 349 350 return gravity; 351 } 352 353 @Override onRemovingChildren(List<INode> deleted, INode parent)354 public void onRemovingChildren(List<INode> deleted, INode parent) { 355 super.onRemovingChildren(deleted, parent); 356 357 // Attempt to clean up spacer objects for any newly-empty rows or columns 358 // as the result of this deletion 359 GridModel grid = new GridModel(mRulesEngine, parent, null); 360 for (INode child : deleted) { 361 // We don't care about deletion of spacers 362 if (child.getFqcn().equals(FQCN_SPACE)) { 363 continue; 364 } 365 grid.markDeleted(child); 366 } 367 368 grid.cleanup(); 369 } 370 371 @Override paintResizeFeedback(IGraphics gc, INode node, ResizeState state)372 protected void paintResizeFeedback(IGraphics gc, INode node, ResizeState state) { 373 if (!sGridMode) { 374 GridModel grid = getGrid(state); 375 GridLayoutPainter.paintResizeFeedback(gc, state.layout, grid); 376 } 377 378 if (resizingWidget(state)) { 379 super.paintResizeFeedback(gc, node, state); 380 } else { 381 GridModel grid = getGrid(state); 382 int startColumn = grid.getColumn(state.bounds.x); 383 int endColumn = grid.getColumn(state.bounds.x2()); 384 int columnSpan = endColumn - startColumn + 1; 385 386 int startRow = grid.getRow(state.bounds.y); 387 int endRow = grid.getRow(state.bounds.y2()); 388 int rowSpan = endRow - startRow + 1; 389 390 Rect cellBounds = grid.getCellBounds(startRow, startColumn, rowSpan, columnSpan); 391 gc.useStyle(DrawingStyle.RESIZE_PREVIEW); 392 gc.drawRect(cellBounds); 393 } 394 } 395 396 /** Returns the grid size cached on the given {@link ResizeState} object */ getGrid(ResizeState resizeState)397 private GridModel getGrid(ResizeState resizeState) { 398 GridModel grid = (GridModel) resizeState.clientData; 399 if (grid == null) { 400 grid = new GridModel(mRulesEngine, resizeState.layout, resizeState.layoutView); 401 resizeState.clientData = grid; 402 } 403 404 return grid; 405 } 406 407 @Override setNewSizeBounds(ResizeState state, INode node, INode layout, Rect oldBounds, Rect newBounds, SegmentType horizontalEdge, SegmentType verticalEdge)408 protected void setNewSizeBounds(ResizeState state, INode node, INode layout, 409 Rect oldBounds, Rect newBounds, SegmentType horizontalEdge, SegmentType verticalEdge) { 410 411 if (resizingWidget(state)) { 412 super.setNewSizeBounds(state, node, layout, oldBounds, newBounds, horizontalEdge, 413 verticalEdge); 414 } else { 415 Pair<Integer, Integer> spans = computeResizeSpans(state); 416 int rowSpan = spans.getFirst(); 417 int columnSpan = spans.getSecond(); 418 GridModel.setColumnSpanAttribute(node, columnSpan); 419 GridModel.setRowSpanAttribute(node, rowSpan); 420 } 421 } 422 423 @Override getResizeUpdateMessage(ResizeState state, INode child, INode parent, Rect newBounds, SegmentType horizontalEdge, SegmentType verticalEdge)424 protected String getResizeUpdateMessage(ResizeState state, INode child, INode parent, 425 Rect newBounds, SegmentType horizontalEdge, SegmentType verticalEdge) { 426 Pair<Integer, Integer> spans = computeResizeSpans(state); 427 if (resizingWidget(state)) { 428 String width = state.getWidthAttribute(); 429 String height = state.getHeightAttribute(); 430 431 String message; 432 if (horizontalEdge == null) { 433 message = width; 434 } else if (verticalEdge == null) { 435 message = height; 436 } else { 437 // U+00D7: Unicode for multiplication sign 438 message = String.format("%s \u00D7 %s", width, height); 439 } 440 441 // Tack on a tip about using the Shift modifier key 442 return String.format("%s\n(Press Shift to resize row/column spans)", message); 443 } else { 444 int rowSpan = spans.getFirst(); 445 int columnSpan = spans.getSecond(); 446 return String.format("ColumnSpan=%d, RowSpan=%d\n(Release Shift to resize widget itself)", 447 columnSpan, rowSpan); 448 } 449 } 450 451 /** 452 * Returns true if we're resizing the widget, and false if we're resizing the cell 453 * spans 454 */ resizingWidget(ResizeState state)455 private static boolean resizingWidget(ResizeState state) { 456 return (state.modifierMask & DropFeedback.MODIFIER2) == 0; 457 } 458 459 /** 460 * Computes the new column and row spans as the result of the current resizing 461 * operation 462 */ computeResizeSpans(ResizeState state)463 private Pair<Integer, Integer> computeResizeSpans(ResizeState state) { 464 GridModel grid = getGrid(state); 465 466 int startColumn = grid.getColumn(state.bounds.x); 467 int endColumn = grid.getColumn(state.bounds.x2()); 468 int columnSpan = endColumn - startColumn + 1; 469 470 int startRow = grid.getRow(state.bounds.y); 471 int endRow = grid.getRow(state.bounds.y2()); 472 int rowSpan = endRow - startRow + 1; 473 474 return Pair.of(rowSpan, columnSpan); 475 } 476 477 /** 478 * Returns the size of the new cell gutter in layout coordinates 479 * 480 * @return the size of the new cell gutter in layout coordinates 481 */ getNewCellSize()482 public int getNewCellSize() { 483 return mRulesEngine.screenToLayout(NEW_CELL_WIDTH / 2); 484 } 485 486 @Override paintSelectionFeedback(IGraphics graphics, INode parentNode, List<? extends INode> childNodes, Object view)487 public void paintSelectionFeedback(IGraphics graphics, INode parentNode, 488 List<? extends INode> childNodes, Object view) { 489 super.paintSelectionFeedback(graphics, parentNode, childNodes, view); 490 491 if (sShowStructure) { 492 // TODO: Cache the grid 493 if (view != null) { 494 GridLayoutPainter.paintStructure(view, DrawingStyle.GUIDELINE_DASHED, 495 parentNode, graphics); 496 } else { 497 GridLayoutPainter.paintStructure(DrawingStyle.GUIDELINE_DASHED, 498 parentNode, graphics, new GridModel(mRulesEngine, parentNode, view)); 499 } 500 } else if (sDebugGridLayout) { 501 GridLayoutPainter.paintStructure(DrawingStyle.GRID, 502 parentNode, graphics, new GridModel(mRulesEngine, parentNode, view)); 503 } 504 505 // TBD: Highlight the cells around the selection, and display easy controls 506 // for for example tweaking the rowspan/colspan of a cell? (but only in grid mode) 507 } 508 } 509