1/* 2 * Copyright (C) 2010 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 17package com.android.ide.common.layout; 18 19import static com.android.SdkConstants.ANDROID_URI; 20import static com.android.SdkConstants.ATTR_ID; 21import static com.android.SdkConstants.ATTR_LAYOUT_ABOVE; 22import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BASELINE; 23import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BOTTOM; 24import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_LEFT; 25import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_BOTTOM; 26import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_LEFT; 27import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_RIGHT; 28import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_TOP; 29import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_RIGHT; 30import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_TOP; 31import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_WITH_PARENT_MISSING; 32import static com.android.SdkConstants.ATTR_LAYOUT_BELOW; 33import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_HORIZONTAL; 34import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_IN_PARENT; 35import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_VERTICAL; 36import static com.android.SdkConstants.ATTR_LAYOUT_COLUMN; 37import static com.android.SdkConstants.ATTR_LAYOUT_COLUMN_SPAN; 38import static com.android.SdkConstants.ATTR_LAYOUT_GRAVITY; 39import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; 40import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN; 41import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_BOTTOM; 42import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_LEFT; 43import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_RIGHT; 44import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_TOP; 45import static com.android.SdkConstants.ATTR_LAYOUT_ROW; 46import static com.android.SdkConstants.ATTR_LAYOUT_ROW_SPAN; 47import static com.android.SdkConstants.ATTR_LAYOUT_TO_LEFT_OF; 48import static com.android.SdkConstants.ATTR_LAYOUT_TO_RIGHT_OF; 49import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; 50import static com.android.SdkConstants.ATTR_LAYOUT_X; 51import static com.android.SdkConstants.ATTR_LAYOUT_Y; 52import static com.android.SdkConstants.VALUE_FILL_PARENT; 53import static com.android.SdkConstants.VALUE_MATCH_PARENT; 54import static com.android.SdkConstants.VALUE_WRAP_CONTENT; 55 56import com.android.SdkConstants; 57import com.android.annotations.NonNull; 58import com.android.annotations.Nullable; 59import com.android.ide.common.api.DrawingStyle; 60import com.android.ide.common.api.DropFeedback; 61import com.android.ide.common.api.IAttributeInfo; 62import com.android.ide.common.api.IClientRulesEngine; 63import com.android.ide.common.api.IDragElement; 64import com.android.ide.common.api.IDragElement.IDragAttribute; 65import com.android.ide.common.api.IFeedbackPainter; 66import com.android.ide.common.api.IGraphics; 67import com.android.ide.common.api.IMenuCallback; 68import com.android.ide.common.api.INode; 69import com.android.ide.common.api.INodeHandler; 70import com.android.ide.common.api.IViewRule; 71import com.android.ide.common.api.MarginType; 72import com.android.ide.common.api.Point; 73import com.android.ide.common.api.Rect; 74import com.android.ide.common.api.RuleAction; 75import com.android.ide.common.api.RuleAction.ChoiceProvider; 76import com.android.ide.common.api.Segment; 77import com.android.ide.common.api.SegmentType; 78import com.android.utils.Pair; 79 80import java.net.URL; 81import java.util.Arrays; 82import java.util.Collections; 83import java.util.HashMap; 84import java.util.HashSet; 85import java.util.List; 86import java.util.Map; 87import java.util.Set; 88 89/** 90 * A {@link IViewRule} for all layouts. 91 */ 92public class BaseLayoutRule extends BaseViewRule { 93 private static final String ACTION_FILL_WIDTH = "_fillW"; //$NON-NLS-1$ 94 private static final String ACTION_FILL_HEIGHT = "_fillH"; //$NON-NLS-1$ 95 private static final String ACTION_MARGIN = "_margin"; //$NON-NLS-1$ 96 private static final URL ICON_MARGINS = 97 BaseLayoutRule.class.getResource("margins.png"); //$NON-NLS-1$ 98 private static final URL ICON_GRAVITY = 99 BaseLayoutRule.class.getResource("gravity.png"); //$NON-NLS-1$ 100 private static final URL ICON_FILL_WIDTH = 101 BaseLayoutRule.class.getResource("fillwidth.png"); //$NON-NLS-1$ 102 private static final URL ICON_FILL_HEIGHT = 103 BaseLayoutRule.class.getResource("fillheight.png"); //$NON-NLS-1$ 104 105 // ==== Layout Actions support ==== 106 107 // The Margin layout parameters are available for LinearLayout, FrameLayout, RelativeLayout, 108 // and their subclasses. 109 protected final RuleAction createMarginAction(final INode parentNode, 110 final List<? extends INode> children) { 111 112 final List<? extends INode> targets = children == null || children.size() == 0 ? 113 Collections.singletonList(parentNode) 114 : children; 115 final INode first = targets.get(0); 116 117 IMenuCallback actionCallback = new IMenuCallback() { 118 @Override 119 public void action(@NonNull RuleAction action, 120 @NonNull List<? extends INode> selectedNodes, 121 final @Nullable String valueId, 122 final @Nullable Boolean newValue) { 123 parentNode.editXml("Change Margins", new INodeHandler() { 124 @Override 125 public void handle(@NonNull INode n) { 126 String uri = ANDROID_URI; 127 String all = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN); 128 String left = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_LEFT); 129 String right = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_RIGHT); 130 String top = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_TOP); 131 String bottom = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_BOTTOM); 132 String[] margins = mRulesEngine.displayMarginInput(all, left, 133 right, top, bottom); 134 if (margins != null) { 135 assert margins.length == 5; 136 for (INode child : targets) { 137 child.setAttribute(uri, ATTR_LAYOUT_MARGIN, margins[0]); 138 child.setAttribute(uri, ATTR_LAYOUT_MARGIN_LEFT, margins[1]); 139 child.setAttribute(uri, ATTR_LAYOUT_MARGIN_RIGHT, margins[2]); 140 child.setAttribute(uri, ATTR_LAYOUT_MARGIN_TOP, margins[3]); 141 child.setAttribute(uri, ATTR_LAYOUT_MARGIN_BOTTOM, margins[4]); 142 } 143 } 144 } 145 }); 146 } 147 }; 148 149 return RuleAction.createAction(ACTION_MARGIN, "Change Margins...", actionCallback, 150 ICON_MARGINS, 40, false); 151 } 152 153 // Both LinearLayout and RelativeLayout have a gravity (but RelativeLayout applies it 154 // to the parent whereas for LinearLayout it's on the children) 155 protected final RuleAction createGravityAction(final List<? extends INode> targets, final 156 String attributeName) { 157 if (targets != null && targets.size() > 0) { 158 final INode first = targets.get(0); 159 ChoiceProvider provider = new ChoiceProvider() { 160 @Override 161 public void addChoices(@NonNull List<String> titles, @NonNull List<URL> iconUrls, 162 @NonNull List<String> ids) { 163 IAttributeInfo info = first.getAttributeInfo(ANDROID_URI, attributeName); 164 if (info != null) { 165 // Generate list of possible gravity value constants 166 assert info.getFormats().contains(IAttributeInfo.Format.FLAG); 167 for (String name : info.getFlagValues()) { 168 titles.add(getAttributeDisplayName(name)); 169 ids.add(name); 170 } 171 } 172 } 173 }; 174 175 return RuleAction.createChoices("_gravity", "Change Gravity", //$NON-NLS-1$ 176 new PropertyCallback(targets, "Change Gravity", ANDROID_URI, 177 attributeName), 178 provider, 179 first.getStringAttr(ANDROID_URI, attributeName), ICON_GRAVITY, 180 43, false); 181 } 182 183 return null; 184 } 185 186 @Override 187 public void addLayoutActions( 188 @NonNull List<RuleAction> actions, 189 final @NonNull INode parentNode, 190 final @NonNull List<? extends INode> children) { 191 super.addLayoutActions(actions, parentNode, children); 192 193 final List<? extends INode> targets = children == null || children.size() == 0 ? 194 Collections.singletonList(parentNode) 195 : children; 196 final INode first = targets.get(0); 197 198 // Shared action callback 199 IMenuCallback actionCallback = new IMenuCallback() { 200 @Override 201 public void action( 202 @NonNull RuleAction action, 203 @NonNull List<? extends INode> selectedNodes, 204 final @Nullable String valueId, 205 final @Nullable Boolean newValue) { 206 final String actionId = action.getId(); 207 final String undoLabel; 208 if (actionId.equals(ACTION_FILL_WIDTH)) { 209 undoLabel = "Change Width Fill"; 210 } else if (actionId.equals(ACTION_FILL_HEIGHT)) { 211 undoLabel = "Change Height Fill"; 212 } else { 213 return; 214 } 215 parentNode.editXml(undoLabel, new INodeHandler() { 216 @Override 217 public void handle(@NonNull INode n) { 218 String attribute = actionId.equals(ACTION_FILL_WIDTH) 219 ? ATTR_LAYOUT_WIDTH : ATTR_LAYOUT_HEIGHT; 220 String value; 221 if (newValue) { 222 if (supportsMatchParent()) { 223 value = VALUE_MATCH_PARENT; 224 } else { 225 value = VALUE_FILL_PARENT; 226 } 227 } else { 228 value = VALUE_WRAP_CONTENT; 229 } 230 for (INode child : targets) { 231 child.setAttribute(ANDROID_URI, attribute, value); 232 } 233 } 234 }); 235 } 236 }; 237 238 actions.add(RuleAction.createToggle(ACTION_FILL_WIDTH, "Toggle Fill Width", 239 isFilled(first, ATTR_LAYOUT_WIDTH), actionCallback, ICON_FILL_WIDTH, 10, false)); 240 actions.add(RuleAction.createToggle(ACTION_FILL_HEIGHT, "Toggle Fill Height", 241 isFilled(first, ATTR_LAYOUT_HEIGHT), actionCallback, ICON_FILL_HEIGHT, 20, false)); 242 } 243 244 // ==== Paste support ==== 245 246 /** 247 * The default behavior for pasting in a layout is to simulate a drop in the 248 * top-left corner of the view. 249 * <p/> 250 * Note that we explicitly do not call super() here -- the BaseViewRule.onPaste handler 251 * will call onPasteBeforeChild() instead. 252 * <p/> 253 * Derived layouts should override this behavior if not appropriate. 254 */ 255 @Override 256 public void onPaste(@NonNull INode targetNode, @Nullable Object targetView, 257 @NonNull IDragElement[] elements) { 258 DropFeedback feedback = onDropEnter(targetNode, targetView, elements); 259 if (feedback != null) { 260 Point p = targetNode.getBounds().getTopLeft(); 261 feedback = onDropMove(targetNode, elements, feedback, p); 262 if (feedback != null) { 263 onDropLeave(targetNode, elements, feedback); 264 onDropped(targetNode, elements, feedback, p); 265 } 266 } 267 } 268 269 /** 270 * The default behavior for pasting in a layout with a specific child target 271 * is to simulate a drop right above the top left of the given child target. 272 * <p/> 273 * This method is invoked by BaseView when onPaste() is called -- 274 * views don't generally accept children and instead use the target node as 275 * a hint to paste "before" it. 276 * 277 * @param parentNode the parent node we're pasting into 278 * @param parentView the view object for the parent layout, or null 279 * @param targetNode the first selected node 280 * @param elements the elements being pasted 281 */ 282 public void onPasteBeforeChild(INode parentNode, Object parentView, INode targetNode, 283 IDragElement[] elements) { 284 DropFeedback feedback = onDropEnter(parentNode, parentView, elements); 285 if (feedback != null) { 286 Point parentP = parentNode.getBounds().getTopLeft(); 287 Point targetP = targetNode.getBounds().getTopLeft(); 288 if (parentP.y < targetP.y) { 289 targetP.y -= 1; 290 } 291 292 feedback = onDropMove(parentNode, elements, feedback, targetP); 293 if (feedback != null) { 294 onDropLeave(parentNode, elements, feedback); 295 onDropped(parentNode, elements, feedback, targetP); 296 } 297 } 298 } 299 300 // ==== Utility methods used by derived layouts ==== 301 302 /** 303 * Draws the bounds of the given elements and all its children elements in the canvas 304 * with the specified offset. 305 * 306 * @param gc the graphics context 307 * @param element the element to be drawn 308 * @param offsetX a horizontal delta to add to the current bounds of the element when 309 * drawing it 310 * @param offsetY a vertical delta to add to the current bounds of the element when 311 * drawing it 312 */ 313 public void drawElement(IGraphics gc, IDragElement element, int offsetX, int offsetY) { 314 Rect b = element.getBounds(); 315 if (b.isValid()) { 316 gc.drawRect(b.x + offsetX, b.y + offsetY, b.x + offsetX + b.w, b.y + offsetY + b.h); 317 } 318 319 for (IDragElement inner : element.getInnerElements()) { 320 drawElement(gc, inner, offsetX, offsetY); 321 } 322 } 323 324 /** 325 * Collect all the "android:id" IDs from the dropped elements. When moving 326 * objects within the same canvas, that's all there is to do. However if the 327 * objects are moved to a different canvas or are copied then set 328 * createNewIds to true to find the existing IDs under targetNode and create 329 * a map with new non-conflicting unique IDs as needed. Returns a map String 330 * old-id => tuple (String new-id, String fqcn) where fqcn is the FQCN of 331 * the element. 332 */ 333 protected static Map<String, Pair<String, String>> getDropIdMap(INode targetNode, 334 IDragElement[] elements, boolean createNewIds) { 335 Map<String, Pair<String, String>> idMap = new HashMap<String, Pair<String, String>>(); 336 337 if (createNewIds) { 338 collectIds(idMap, elements); 339 // Need to remap ids if necessary 340 idMap = remapIds(targetNode, idMap); 341 } 342 343 return idMap; 344 } 345 346 /** 347 * Fills idMap with a map String id => tuple (String id, String fqcn) where 348 * fqcn is the FQCN of the element (in case we want to generate new IDs 349 * based on the element type.) 350 * 351 * @see #getDropIdMap 352 */ 353 protected static Map<String, Pair<String, String>> collectIds( 354 Map<String, Pair<String, String>> idMap, 355 IDragElement[] elements) { 356 for (IDragElement element : elements) { 357 IDragAttribute attr = element.getAttribute(ANDROID_URI, ATTR_ID); 358 if (attr != null) { 359 String id = attr.getValue(); 360 if (id != null && id.length() > 0) { 361 idMap.put(id, Pair.of(id, element.getFqcn())); 362 } 363 } 364 365 collectIds(idMap, element.getInnerElements()); 366 } 367 368 return idMap; 369 } 370 371 /** 372 * Used by #getDropIdMap to find new IDs in case of conflict. 373 */ 374 protected static Map<String, Pair<String, String>> remapIds(INode node, 375 Map<String, Pair<String, String>> idMap) { 376 // Visit the document to get a list of existing ids 377 Set<String> existingIdSet = new HashSet<String>(); 378 collectExistingIds(node.getRoot(), existingIdSet); 379 380 Map<String, Pair<String, String>> new_map = new HashMap<String, Pair<String, String>>(); 381 for (Map.Entry<String, Pair<String, String>> entry : idMap.entrySet()) { 382 String key = entry.getKey(); 383 Pair<String, String> value = entry.getValue(); 384 385 String id = normalizeId(key); 386 387 if (!existingIdSet.contains(id)) { 388 // Not a conflict. Use as-is. 389 new_map.put(key, value); 390 if (!key.equals(id)) { 391 new_map.put(id, value); 392 } 393 } else { 394 // There is a conflict. Get a new id. 395 String new_id = findNewId(value.getSecond(), existingIdSet); 396 value = Pair.of(new_id, value.getSecond()); 397 new_map.put(id, value); 398 new_map.put(id.replaceFirst("@\\+", "@"), value); //$NON-NLS-1$ //$NON-NLS-2$ 399 } 400 } 401 402 return new_map; 403 } 404 405 /** 406 * Used by #remapIds to find a new ID for a conflicting element. 407 */ 408 protected static String findNewId(String fqcn, Set<String> existingIdSet) { 409 // Get the last component of the FQCN (e.g. "android.view.Button" => 410 // "Button") 411 String name = fqcn.substring(fqcn.lastIndexOf('.') + 1); 412 413 for (int i = 1; i < 1000000; i++) { 414 String id = String.format("@+id/%s%02d", name, i); //$NON-NLS-1$ 415 if (!existingIdSet.contains(id)) { 416 existingIdSet.add(id); 417 return id; 418 } 419 } 420 421 // We'll never reach here. 422 return null; 423 } 424 425 /** 426 * Used by #getDropIdMap to find existing IDs recursively. 427 */ 428 protected static void collectExistingIds(INode root, Set<String> existingIdSet) { 429 if (root == null) { 430 return; 431 } 432 433 String id = root.getStringAttr(ANDROID_URI, ATTR_ID); 434 if (id != null) { 435 id = normalizeId(id); 436 437 if (!existingIdSet.contains(id)) { 438 existingIdSet.add(id); 439 } 440 } 441 442 for (INode child : root.getChildren()) { 443 collectExistingIds(child, existingIdSet); 444 } 445 } 446 447 /** 448 * Transforms @id/name into @+id/name to treat both forms the same way. 449 */ 450 protected static String normalizeId(String id) { 451 if (id.indexOf("@+") == -1) { //$NON-NLS-1$ 452 id = id.replaceFirst("@", "@+"); //$NON-NLS-1$ //$NON-NLS-2$ 453 } 454 return id; 455 } 456 457 /** 458 * For use by {@link BaseLayoutRule#addAttributes} A filter should return a 459 * valid replacement string. 460 */ 461 protected static interface AttributeFilter { 462 String replace(String attributeUri, String attributeName, String attributeValue); 463 } 464 465 private static final String[] EXCLUDED_ATTRIBUTES = new String[] { 466 // Common 467 ATTR_LAYOUT_GRAVITY, 468 469 // from AbsoluteLayout 470 ATTR_LAYOUT_X, 471 ATTR_LAYOUT_Y, 472 473 // from RelativeLayout 474 ATTR_LAYOUT_ABOVE, 475 ATTR_LAYOUT_BELOW, 476 ATTR_LAYOUT_TO_LEFT_OF, 477 ATTR_LAYOUT_TO_RIGHT_OF, 478 ATTR_LAYOUT_ALIGN_BASELINE, 479 ATTR_LAYOUT_ALIGN_TOP, 480 ATTR_LAYOUT_ALIGN_BOTTOM, 481 ATTR_LAYOUT_ALIGN_LEFT, 482 ATTR_LAYOUT_ALIGN_RIGHT, 483 ATTR_LAYOUT_ALIGN_PARENT_TOP, 484 ATTR_LAYOUT_ALIGN_PARENT_BOTTOM, 485 ATTR_LAYOUT_ALIGN_PARENT_LEFT, 486 ATTR_LAYOUT_ALIGN_PARENT_RIGHT, 487 ATTR_LAYOUT_ALIGN_WITH_PARENT_MISSING, 488 ATTR_LAYOUT_CENTER_HORIZONTAL, 489 ATTR_LAYOUT_CENTER_IN_PARENT, 490 ATTR_LAYOUT_CENTER_VERTICAL, 491 492 // From GridLayout 493 ATTR_LAYOUT_ROW, 494 ATTR_LAYOUT_ROW_SPAN, 495 ATTR_LAYOUT_COLUMN, 496 ATTR_LAYOUT_COLUMN_SPAN 497 }; 498 499 /** 500 * Default attribute filter used by the various layouts to filter out some properties 501 * we don't want to offer. 502 */ 503 public static final AttributeFilter DEFAULT_ATTR_FILTER = new AttributeFilter() { 504 Set<String> mExcludes; 505 506 @Override 507 public String replace(String uri, String name, String value) { 508 if (!ANDROID_URI.equals(uri)) { 509 return value; 510 } 511 512 if (mExcludes == null) { 513 mExcludes = new HashSet<String>(EXCLUDED_ATTRIBUTES.length); 514 mExcludes.addAll(Arrays.asList(EXCLUDED_ATTRIBUTES)); 515 } 516 517 return mExcludes.contains(name) ? null : value; 518 } 519 }; 520 521 /** 522 * Copies all the attributes from oldElement to newNode. Uses the idMap to 523 * transform the value of all attributes of Format.REFERENCE. If filter is 524 * non-null, it's a filter that can rewrite the attribute string. 525 */ 526 protected static void addAttributes(INode newNode, IDragElement oldElement, 527 Map<String, Pair<String, String>> idMap, AttributeFilter filter) { 528 529 for (IDragAttribute attr : oldElement.getAttributes()) { 530 String uri = attr.getUri(); 531 String name = attr.getName(); 532 String value = attr.getValue(); 533 534 IAttributeInfo attrInfo = newNode.getAttributeInfo(uri, name); 535 if (attrInfo != null) { 536 if (attrInfo.getFormats().contains(IAttributeInfo.Format.REFERENCE)) { 537 if (idMap.containsKey(value)) { 538 value = idMap.get(value).getFirst(); 539 } 540 } 541 } 542 543 if (filter != null) { 544 value = filter.replace(uri, name, value); 545 } 546 if (value != null && value.length() > 0) { 547 newNode.setAttribute(uri, name, value); 548 } 549 } 550 } 551 552 /** 553 * Adds all the children elements of oldElement to newNode, recursively. 554 * Attributes are adjusted by calling addAttributes with idMap as necessary, 555 * with no closure filter. 556 */ 557 protected static void addInnerElements(INode newNode, IDragElement oldElement, 558 Map<String, Pair<String, String>> idMap) { 559 560 for (IDragElement element : oldElement.getInnerElements()) { 561 String fqcn = element.getFqcn(); 562 INode childNode = newNode.appendChild(fqcn); 563 564 addAttributes(childNode, element, idMap, null /* filter */); 565 addInnerElements(childNode, element, idMap); 566 } 567 } 568 569 /** 570 * Insert the given elements into the given node at the given position 571 * 572 * @param targetNode the node to insert into 573 * @param elements the elements to insert 574 * @param createNewIds if true, generate new ids when there is a conflict 575 * @param initialInsertPos index among targetnode's children which to insert the 576 * children 577 */ 578 public static void insertAt(final INode targetNode, final IDragElement[] elements, 579 final boolean createNewIds, final int initialInsertPos) { 580 581 // Collect IDs from dropped elements and remap them to new IDs 582 // if this is a copy or from a different canvas. 583 final Map<String, Pair<String, String>> idMap = getDropIdMap(targetNode, elements, 584 createNewIds); 585 586 targetNode.editXml("Insert Elements", new INodeHandler() { 587 588 @Override 589 public void handle(@NonNull INode node) { 590 // Now write the new elements. 591 int insertPos = initialInsertPos; 592 for (IDragElement element : elements) { 593 String fqcn = element.getFqcn(); 594 595 INode newChild = targetNode.insertChildAt(fqcn, insertPos); 596 597 // insertPos==-1 means to insert at the end. Otherwise 598 // increment the insertion position. 599 if (insertPos >= 0) { 600 insertPos++; 601 } 602 603 // Copy all the attributes, modifying them as needed. 604 addAttributes(newChild, element, idMap, DEFAULT_ATTR_FILTER); 605 addInnerElements(newChild, element, idMap); 606 } 607 } 608 }); 609 } 610 611 // ---- Resizing ---- 612 613 /** Creates a new {@link ResizeState} object to track resize state */ 614 protected ResizeState createResizeState(INode layout, Object layoutView, INode node) { 615 return new ResizeState(this, layout, layoutView, node); 616 } 617 618 @Override 619 public DropFeedback onResizeBegin(@NonNull INode child, @NonNull INode parent, 620 @Nullable SegmentType horizontalEdge, @Nullable SegmentType verticalEdge, 621 @Nullable Object childView, @Nullable Object parentView) { 622 ResizeState state = createResizeState(parent, parentView, child); 623 state.horizontalEdgeType = horizontalEdge; 624 state.verticalEdgeType = verticalEdge; 625 626 // Compute preferred (wrap_content) size such that we can offer guidelines to 627 // snap to the preferred size 628 Map<INode, Rect> sizes = mRulesEngine.measureChildren(parent, 629 new IClientRulesEngine.AttributeFilter() { 630 @Override 631 public String getAttribute(@NonNull INode node, @Nullable String namespace, 632 @NonNull String localName) { 633 // Change attributes to wrap_content 634 if (ATTR_LAYOUT_WIDTH.equals(localName) 635 && SdkConstants.NS_RESOURCES.equals(namespace)) { 636 return VALUE_WRAP_CONTENT; 637 } 638 if (ATTR_LAYOUT_HEIGHT.equals(localName) 639 && SdkConstants.NS_RESOURCES.equals(namespace)) { 640 return VALUE_WRAP_CONTENT; 641 } 642 643 return null; 644 } 645 }); 646 if (sizes != null) { 647 state.wrapBounds = sizes.get(child); 648 } 649 650 return new DropFeedback(state, new IFeedbackPainter() { 651 @Override 652 public void paint(@NonNull IGraphics gc, @NonNull INode node, 653 @NonNull DropFeedback feedback) { 654 ResizeState resizeState = (ResizeState) feedback.userData; 655 if (resizeState != null && resizeState.bounds != null) { 656 paintResizeFeedback(gc, node, resizeState); 657 } 658 } 659 }); 660 } 661 662 protected void paintResizeFeedback(IGraphics gc, INode node, ResizeState resizeState) { 663 gc.useStyle(DrawingStyle.RESIZE_PREVIEW); 664 Rect b = resizeState.bounds; 665 gc.drawRect(b); 666 667 if (resizeState.horizontalFillSegment != null) { 668 gc.useStyle(DrawingStyle.GUIDELINE); 669 Segment s = resizeState.horizontalFillSegment; 670 gc.drawLine(s.from, s.at, s.to, s.at); 671 } 672 if (resizeState.verticalFillSegment != null) { 673 gc.useStyle(DrawingStyle.GUIDELINE); 674 Segment s = resizeState.verticalFillSegment; 675 gc.drawLine(s.at, s.from, s.at, s.to); 676 } 677 678 if (resizeState.wrapBounds != null) { 679 gc.useStyle(DrawingStyle.GUIDELINE); 680 int wrapWidth = resizeState.wrapBounds.w; 681 int wrapHeight = resizeState.wrapBounds.h; 682 683 // Show the "wrap_content" guideline. 684 // If we are showing both the wrap_width and wrap_height lines 685 // then we show at most the rectangle formed by the two lines; 686 // otherwise we show the entire width of the line 687 if (resizeState.horizontalEdgeType != null) { 688 int y = -1; 689 switch (resizeState.horizontalEdgeType) { 690 case TOP: 691 y = b.y + b.h - wrapHeight; 692 break; 693 case BOTTOM: 694 y = b.y + wrapHeight; 695 break; 696 default: assert false : resizeState.horizontalEdgeType; 697 } 698 if (resizeState.verticalEdgeType != null) { 699 switch (resizeState.verticalEdgeType) { 700 case LEFT: 701 gc.drawLine(b.x + b.w - wrapWidth, y, b.x + b.w, y); 702 break; 703 case RIGHT: 704 gc.drawLine(b.x, y, b.x + wrapWidth, y); 705 break; 706 default: assert false : resizeState.verticalEdgeType; 707 } 708 } else { 709 gc.drawLine(b.x, y, b.x + b.w, y); 710 } 711 } 712 if (resizeState.verticalEdgeType != null) { 713 int x = -1; 714 switch (resizeState.verticalEdgeType) { 715 case LEFT: 716 x = b.x + b.w - wrapWidth; 717 break; 718 case RIGHT: 719 x = b.x + wrapWidth; 720 break; 721 default: assert false : resizeState.verticalEdgeType; 722 } 723 if (resizeState.horizontalEdgeType != null) { 724 switch (resizeState.horizontalEdgeType) { 725 case TOP: 726 gc.drawLine(x, b.y + b.h - wrapHeight, x, b.y + b.h); 727 break; 728 case BOTTOM: 729 gc.drawLine(x, b.y, x, b.y + wrapHeight); 730 break; 731 default: assert false : resizeState.horizontalEdgeType; 732 } 733 } else { 734 gc.drawLine(x, b.y, x, b.y + b.h); 735 } 736 } 737 } 738 } 739 740 /** 741 * Returns the maximum number of pixels will be considered a "match" when snapping 742 * resize or move positions to edges or other constraints 743 * 744 * @return the maximum number of pixels to consider for snapping 745 */ 746 public static final int getMaxMatchDistance() { 747 // TODO - make constant once we're happy with the feel 748 return 20; 749 } 750 751 @Override 752 public void onResizeUpdate(@Nullable DropFeedback feedback, @NonNull INode child, 753 @NonNull INode parent, @NonNull Rect newBounds, int modifierMask) { 754 ResizeState state = (ResizeState) feedback.userData; 755 state.bounds = newBounds; 756 state.modifierMask = modifierMask; 757 758 // Match on wrap bounds 759 state.wrapWidth = state.wrapHeight = false; 760 if (state.wrapBounds != null) { 761 Rect b = state.wrapBounds; 762 int maxMatchDistance = getMaxMatchDistance(); 763 if (state.horizontalEdgeType != null) { 764 if (Math.abs(newBounds.h - b.h) < maxMatchDistance) { 765 state.wrapHeight = true; 766 if (state.horizontalEdgeType == SegmentType.TOP) { 767 newBounds.y += newBounds.h - b.h; 768 } 769 newBounds.h = b.h; 770 } 771 } 772 if (state.verticalEdgeType != null) { 773 if (Math.abs(newBounds.w - b.w) < maxMatchDistance) { 774 state.wrapWidth = true; 775 if (state.verticalEdgeType == SegmentType.LEFT) { 776 newBounds.x += newBounds.w - b.w; 777 } 778 newBounds.w = b.w; 779 } 780 } 781 } 782 783 // Match on fill bounds 784 state.horizontalFillSegment = null; 785 state.fillHeight = false; 786 if (state.horizontalEdgeType == SegmentType.BOTTOM && !state.wrapHeight) { 787 Rect parentBounds = parent.getBounds(); 788 state.horizontalFillSegment = new Segment(parentBounds.y2(), newBounds.x, 789 newBounds.x2(), 790 null /*node*/, null /*id*/, SegmentType.BOTTOM, MarginType.NO_MARGIN); 791 if (Math.abs(newBounds.y2() - parentBounds.y2()) < getMaxMatchDistance()) { 792 state.fillHeight = true; 793 newBounds.h = parentBounds.y2() - newBounds.y; 794 } 795 } 796 state.verticalFillSegment = null; 797 state.fillWidth = false; 798 if (state.verticalEdgeType == SegmentType.RIGHT && !state.wrapWidth) { 799 Rect parentBounds = parent.getBounds(); 800 state.verticalFillSegment = new Segment(parentBounds.x2(), newBounds.y, 801 newBounds.y2(), 802 null /*node*/, null /*id*/, SegmentType.RIGHT, MarginType.NO_MARGIN); 803 if (Math.abs(newBounds.x2() - parentBounds.x2()) < getMaxMatchDistance()) { 804 state.fillWidth = true; 805 newBounds.w = parentBounds.x2() - newBounds.x; 806 } 807 } 808 809 feedback.tooltip = getResizeUpdateMessage(state, child, parent, 810 newBounds, state.horizontalEdgeType, state.verticalEdgeType); 811 } 812 813 @Override 814 public void onResizeEnd(@Nullable DropFeedback feedback, @NonNull INode child, 815 final @NonNull INode parent, final @NonNull Rect newBounds) { 816 final Rect oldBounds = child.getBounds(); 817 if (oldBounds.w != newBounds.w || oldBounds.h != newBounds.h) { 818 final ResizeState state = (ResizeState) feedback.userData; 819 child.editXml("Resize", new INodeHandler() { 820 @Override 821 public void handle(@NonNull INode n) { 822 setNewSizeBounds(state, n, parent, oldBounds, newBounds, 823 state.horizontalEdgeType, state.verticalEdgeType); 824 } 825 }); 826 } 827 } 828 829 /** 830 * Returns the message to display to the user during the resize operation 831 * 832 * @param resizeState the current resize state 833 * @param child the child node being resized 834 * @param parent the parent of the resized node 835 * @param newBounds the new bounds to resize the child to, in pixels 836 * @param horizontalEdge the horizontal edge being resized 837 * @param verticalEdge the vertical edge being resized 838 * @return the message to display for the current resize bounds 839 */ 840 protected String getResizeUpdateMessage(ResizeState resizeState, INode child, INode parent, 841 Rect newBounds, SegmentType horizontalEdge, SegmentType verticalEdge) { 842 String width = resizeState.getWidthAttribute(); 843 String height = resizeState.getHeightAttribute(); 844 845 if (horizontalEdge == null) { 846 return width; 847 } else if (verticalEdge == null) { 848 return height; 849 } else { 850 // U+00D7: Unicode for multiplication sign 851 return String.format("%s \u00D7 %s", width, height); 852 } 853 } 854 855 /** 856 * Performs the edit on the node to complete a resizing operation. The actual edit 857 * part is pulled out such that subclasses can change/add to the edits and be part of 858 * the same undo event 859 * 860 * @param resizeState the current resize state 861 * @param node the child node being resized 862 * @param layout the parent of the resized node 863 * @param newBounds the new bounds to resize the child to, in pixels 864 * @param horizontalEdge the horizontal edge being resized 865 * @param verticalEdge the vertical edge being resized 866 */ 867 protected void setNewSizeBounds(ResizeState resizeState, INode node, INode layout, 868 Rect oldBounds, Rect newBounds, SegmentType horizontalEdge, SegmentType verticalEdge) { 869 if (verticalEdge != null 870 && (newBounds.w != oldBounds.w || resizeState.wrapWidth || resizeState.fillWidth)) { 871 node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, resizeState.getWidthAttribute()); 872 } 873 if (horizontalEdge != null 874 && (newBounds.h != oldBounds.h || resizeState.wrapHeight || resizeState.fillHeight)) { 875 node.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, resizeState.getHeightAttribute()); 876 } 877 } 878} 879