/* * Copyright (C) 2010 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; import static com.android.SdkConstants.ANDROID_URI; import static com.android.SdkConstants.ATTR_ID; import static com.android.SdkConstants.ATTR_LAYOUT_ABOVE; import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BASELINE; import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_BOTTOM; import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_LEFT; import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_BOTTOM; import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_LEFT; import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_RIGHT; import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_PARENT_TOP; import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_RIGHT; import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_TOP; import static com.android.SdkConstants.ATTR_LAYOUT_ALIGN_WITH_PARENT_MISSING; import static com.android.SdkConstants.ATTR_LAYOUT_BELOW; import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_HORIZONTAL; import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_IN_PARENT; import static com.android.SdkConstants.ATTR_LAYOUT_CENTER_VERTICAL; 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_MARGIN; import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_BOTTOM; import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_LEFT; import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_RIGHT; import static com.android.SdkConstants.ATTR_LAYOUT_MARGIN_TOP; import static com.android.SdkConstants.ATTR_LAYOUT_ROW; import static com.android.SdkConstants.ATTR_LAYOUT_ROW_SPAN; import static com.android.SdkConstants.ATTR_LAYOUT_TO_LEFT_OF; import static com.android.SdkConstants.ATTR_LAYOUT_TO_RIGHT_OF; import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; import static com.android.SdkConstants.ATTR_LAYOUT_X; import static com.android.SdkConstants.ATTR_LAYOUT_Y; import static com.android.SdkConstants.VALUE_FILL_PARENT; import static com.android.SdkConstants.VALUE_MATCH_PARENT; import static com.android.SdkConstants.VALUE_WRAP_CONTENT; import com.android.SdkConstants; import com.android.annotations.NonNull; import com.android.annotations.Nullable; import com.android.ide.common.api.DrawingStyle; import com.android.ide.common.api.DropFeedback; import com.android.ide.common.api.IAttributeInfo; import com.android.ide.common.api.IClientRulesEngine; import com.android.ide.common.api.IDragElement; import com.android.ide.common.api.IDragElement.IDragAttribute; import com.android.ide.common.api.IFeedbackPainter; import com.android.ide.common.api.IGraphics; import com.android.ide.common.api.IMenuCallback; import com.android.ide.common.api.INode; import com.android.ide.common.api.INodeHandler; import com.android.ide.common.api.IViewRule; import com.android.ide.common.api.MarginType; import com.android.ide.common.api.Point; import com.android.ide.common.api.Rect; import com.android.ide.common.api.RuleAction; import com.android.ide.common.api.RuleAction.ChoiceProvider; import com.android.ide.common.api.Segment; import com.android.ide.common.api.SegmentType; import com.android.utils.Pair; import java.net.URL; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; /** * A {@link IViewRule} for all layouts. */ public class BaseLayoutRule extends BaseViewRule { private static final String ACTION_FILL_WIDTH = "_fillW"; //$NON-NLS-1$ private static final String ACTION_FILL_HEIGHT = "_fillH"; //$NON-NLS-1$ private static final String ACTION_MARGIN = "_margin"; //$NON-NLS-1$ private static final URL ICON_MARGINS = BaseLayoutRule.class.getResource("margins.png"); //$NON-NLS-1$ private static final URL ICON_GRAVITY = BaseLayoutRule.class.getResource("gravity.png"); //$NON-NLS-1$ private static final URL ICON_FILL_WIDTH = BaseLayoutRule.class.getResource("fillwidth.png"); //$NON-NLS-1$ private static final URL ICON_FILL_HEIGHT = BaseLayoutRule.class.getResource("fillheight.png"); //$NON-NLS-1$ // ==== Layout Actions support ==== // The Margin layout parameters are available for LinearLayout, FrameLayout, RelativeLayout, // and their subclasses. protected final RuleAction createMarginAction(final INode parentNode, final List<? extends INode> children) { final List<? extends INode> targets = children == null || children.size() == 0 ? Collections.singletonList(parentNode) : children; final INode first = targets.get(0); IMenuCallback actionCallback = new IMenuCallback() { @Override public void action(@NonNull RuleAction action, @NonNull List<? extends INode> selectedNodes, final @Nullable String valueId, final @Nullable Boolean newValue) { parentNode.editXml("Change Margins", new INodeHandler() { @Override public void handle(@NonNull INode n) { String uri = ANDROID_URI; String all = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN); String left = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_LEFT); String right = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_RIGHT); String top = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_TOP); String bottom = first.getStringAttr(uri, ATTR_LAYOUT_MARGIN_BOTTOM); String[] margins = mRulesEngine.displayMarginInput(all, left, right, top, bottom); if (margins != null) { assert margins.length == 5; for (INode child : targets) { child.setAttribute(uri, ATTR_LAYOUT_MARGIN, margins[0]); child.setAttribute(uri, ATTR_LAYOUT_MARGIN_LEFT, margins[1]); child.setAttribute(uri, ATTR_LAYOUT_MARGIN_RIGHT, margins[2]); child.setAttribute(uri, ATTR_LAYOUT_MARGIN_TOP, margins[3]); child.setAttribute(uri, ATTR_LAYOUT_MARGIN_BOTTOM, margins[4]); } } } }); } }; return RuleAction.createAction(ACTION_MARGIN, "Change Margins...", actionCallback, ICON_MARGINS, 40, false); } // Both LinearLayout and RelativeLayout have a gravity (but RelativeLayout applies it // to the parent whereas for LinearLayout it's on the children) protected final RuleAction createGravityAction(final List<? extends INode> targets, final String attributeName) { if (targets != null && targets.size() > 0) { final INode first = targets.get(0); ChoiceProvider provider = new ChoiceProvider() { @Override public void addChoices(@NonNull List<String> titles, @NonNull List<URL> iconUrls, @NonNull List<String> ids) { IAttributeInfo info = first.getAttributeInfo(ANDROID_URI, attributeName); if (info != null) { // Generate list of possible gravity value constants assert info.getFormats().contains(IAttributeInfo.Format.FLAG); for (String name : info.getFlagValues()) { titles.add(getAttributeDisplayName(name)); ids.add(name); } } } }; return RuleAction.createChoices("_gravity", "Change Gravity", //$NON-NLS-1$ new PropertyCallback(targets, "Change Gravity", ANDROID_URI, attributeName), provider, first.getStringAttr(ANDROID_URI, attributeName), ICON_GRAVITY, 43, false); } return null; } @Override public void addLayoutActions( @NonNull List<RuleAction> actions, final @NonNull INode parentNode, final @NonNull List<? extends INode> children) { super.addLayoutActions(actions, parentNode, children); final List<? extends INode> targets = children == null || children.size() == 0 ? Collections.singletonList(parentNode) : children; final INode first = targets.get(0); // Shared action callback IMenuCallback actionCallback = new IMenuCallback() { @Override public void action( @NonNull RuleAction action, @NonNull List<? extends INode> selectedNodes, final @Nullable String valueId, final @Nullable Boolean newValue) { final String actionId = action.getId(); final String undoLabel; if (actionId.equals(ACTION_FILL_WIDTH)) { undoLabel = "Change Width Fill"; } else if (actionId.equals(ACTION_FILL_HEIGHT)) { undoLabel = "Change Height Fill"; } else { return; } parentNode.editXml(undoLabel, new INodeHandler() { @Override public void handle(@NonNull INode n) { String attribute = actionId.equals(ACTION_FILL_WIDTH) ? ATTR_LAYOUT_WIDTH : ATTR_LAYOUT_HEIGHT; String value; if (newValue) { if (supportsMatchParent()) { value = VALUE_MATCH_PARENT; } else { value = VALUE_FILL_PARENT; } } else { value = VALUE_WRAP_CONTENT; } for (INode child : targets) { child.setAttribute(ANDROID_URI, attribute, value); } } }); } }; actions.add(RuleAction.createToggle(ACTION_FILL_WIDTH, "Toggle Fill Width", isFilled(first, ATTR_LAYOUT_WIDTH), actionCallback, ICON_FILL_WIDTH, 10, false)); actions.add(RuleAction.createToggle(ACTION_FILL_HEIGHT, "Toggle Fill Height", isFilled(first, ATTR_LAYOUT_HEIGHT), actionCallback, ICON_FILL_HEIGHT, 20, false)); } // ==== Paste support ==== /** * The default behavior for pasting in a layout is to simulate a drop in the * top-left corner of the view. * <p/> * Note that we explicitly do not call super() here -- the BaseViewRule.onPaste handler * will call onPasteBeforeChild() instead. * <p/> * Derived layouts should override this behavior if not appropriate. */ @Override public void onPaste(@NonNull INode targetNode, @Nullable Object targetView, @NonNull IDragElement[] elements) { DropFeedback feedback = onDropEnter(targetNode, targetView, elements); if (feedback != null) { Point p = targetNode.getBounds().getTopLeft(); feedback = onDropMove(targetNode, elements, feedback, p); if (feedback != null) { onDropLeave(targetNode, elements, feedback); onDropped(targetNode, elements, feedback, p); } } } /** * The default behavior for pasting in a layout with a specific child target * is to simulate a drop right above the top left of the given child target. * <p/> * This method is invoked by BaseView when onPaste() is called -- * views don't generally accept children and instead use the target node as * a hint to paste "before" it. * * @param parentNode the parent node we're pasting into * @param parentView the view object for the parent layout, or null * @param targetNode the first selected node * @param elements the elements being pasted */ public void onPasteBeforeChild(INode parentNode, Object parentView, INode targetNode, IDragElement[] elements) { DropFeedback feedback = onDropEnter(parentNode, parentView, elements); if (feedback != null) { Point parentP = parentNode.getBounds().getTopLeft(); Point targetP = targetNode.getBounds().getTopLeft(); if (parentP.y < targetP.y) { targetP.y -= 1; } feedback = onDropMove(parentNode, elements, feedback, targetP); if (feedback != null) { onDropLeave(parentNode, elements, feedback); onDropped(parentNode, elements, feedback, targetP); } } } // ==== Utility methods used by derived layouts ==== /** * Draws the bounds of the given elements and all its children elements in the canvas * with the specified offset. * * @param gc the graphics context * @param element the element to be drawn * @param offsetX a horizontal delta to add to the current bounds of the element when * drawing it * @param offsetY a vertical delta to add to the current bounds of the element when * drawing it */ public void drawElement(IGraphics gc, IDragElement element, int offsetX, int offsetY) { Rect b = element.getBounds(); if (b.isValid()) { gc.drawRect(b.x + offsetX, b.y + offsetY, b.x + offsetX + b.w, b.y + offsetY + b.h); } for (IDragElement inner : element.getInnerElements()) { drawElement(gc, inner, offsetX, offsetY); } } /** * Collect all the "android:id" IDs from the dropped elements. When moving * objects within the same canvas, that's all there is to do. However if the * objects are moved to a different canvas or are copied then set * createNewIds to true to find the existing IDs under targetNode and create * a map with new non-conflicting unique IDs as needed. Returns a map String * old-id => tuple (String new-id, String fqcn) where fqcn is the FQCN of * the element. */ protected static Map<String, Pair<String, String>> getDropIdMap(INode targetNode, IDragElement[] elements, boolean createNewIds) { Map<String, Pair<String, String>> idMap = new HashMap<String, Pair<String, String>>(); if (createNewIds) { collectIds(idMap, elements); // Need to remap ids if necessary idMap = remapIds(targetNode, idMap); } return idMap; } /** * Fills idMap with a map String id => tuple (String id, String fqcn) where * fqcn is the FQCN of the element (in case we want to generate new IDs * based on the element type.) * * @see #getDropIdMap */ protected static Map<String, Pair<String, String>> collectIds( Map<String, Pair<String, String>> idMap, IDragElement[] elements) { for (IDragElement element : elements) { IDragAttribute attr = element.getAttribute(ANDROID_URI, ATTR_ID); if (attr != null) { String id = attr.getValue(); if (id != null && id.length() > 0) { idMap.put(id, Pair.of(id, element.getFqcn())); } } collectIds(idMap, element.getInnerElements()); } return idMap; } /** * Used by #getDropIdMap to find new IDs in case of conflict. */ protected static Map<String, Pair<String, String>> remapIds(INode node, Map<String, Pair<String, String>> idMap) { // Visit the document to get a list of existing ids Set<String> existingIdSet = new HashSet<String>(); collectExistingIds(node.getRoot(), existingIdSet); Map<String, Pair<String, String>> new_map = new HashMap<String, Pair<String, String>>(); for (Map.Entry<String, Pair<String, String>> entry : idMap.entrySet()) { String key = entry.getKey(); Pair<String, String> value = entry.getValue(); String id = normalizeId(key); if (!existingIdSet.contains(id)) { // Not a conflict. Use as-is. new_map.put(key, value); if (!key.equals(id)) { new_map.put(id, value); } } else { // There is a conflict. Get a new id. String new_id = findNewId(value.getSecond(), existingIdSet); value = Pair.of(new_id, value.getSecond()); new_map.put(id, value); new_map.put(id.replaceFirst("@\\+", "@"), value); //$NON-NLS-1$ //$NON-NLS-2$ } } return new_map; } /** * Used by #remapIds to find a new ID for a conflicting element. */ protected static String findNewId(String fqcn, Set<String> existingIdSet) { // Get the last component of the FQCN (e.g. "android.view.Button" => // "Button") String name = fqcn.substring(fqcn.lastIndexOf('.') + 1); for (int i = 1; i < 1000000; i++) { String id = String.format("@+id/%s%02d", name, i); //$NON-NLS-1$ if (!existingIdSet.contains(id)) { existingIdSet.add(id); return id; } } // We'll never reach here. return null; } /** * Used by #getDropIdMap to find existing IDs recursively. */ protected static void collectExistingIds(INode root, Set<String> existingIdSet) { if (root == null) { return; } String id = root.getStringAttr(ANDROID_URI, ATTR_ID); if (id != null) { id = normalizeId(id); if (!existingIdSet.contains(id)) { existingIdSet.add(id); } } for (INode child : root.getChildren()) { collectExistingIds(child, existingIdSet); } } /** * Transforms @id/name into @+id/name to treat both forms the same way. */ protected static String normalizeId(String id) { if (id.indexOf("@+") == -1) { //$NON-NLS-1$ id = id.replaceFirst("@", "@+"); //$NON-NLS-1$ //$NON-NLS-2$ } return id; } /** * For use by {@link BaseLayoutRule#addAttributes} A filter should return a * valid replacement string. */ protected static interface AttributeFilter { String replace(String attributeUri, String attributeName, String attributeValue); } private static final String[] EXCLUDED_ATTRIBUTES = new String[] { // Common ATTR_LAYOUT_GRAVITY, // from AbsoluteLayout ATTR_LAYOUT_X, ATTR_LAYOUT_Y, // from RelativeLayout ATTR_LAYOUT_ABOVE, ATTR_LAYOUT_BELOW, ATTR_LAYOUT_TO_LEFT_OF, ATTR_LAYOUT_TO_RIGHT_OF, ATTR_LAYOUT_ALIGN_BASELINE, ATTR_LAYOUT_ALIGN_TOP, ATTR_LAYOUT_ALIGN_BOTTOM, ATTR_LAYOUT_ALIGN_LEFT, ATTR_LAYOUT_ALIGN_RIGHT, ATTR_LAYOUT_ALIGN_PARENT_TOP, ATTR_LAYOUT_ALIGN_PARENT_BOTTOM, ATTR_LAYOUT_ALIGN_PARENT_LEFT, ATTR_LAYOUT_ALIGN_PARENT_RIGHT, ATTR_LAYOUT_ALIGN_WITH_PARENT_MISSING, ATTR_LAYOUT_CENTER_HORIZONTAL, ATTR_LAYOUT_CENTER_IN_PARENT, ATTR_LAYOUT_CENTER_VERTICAL, // From GridLayout ATTR_LAYOUT_ROW, ATTR_LAYOUT_ROW_SPAN, ATTR_LAYOUT_COLUMN, ATTR_LAYOUT_COLUMN_SPAN }; /** * Default attribute filter used by the various layouts to filter out some properties * we don't want to offer. */ public static final AttributeFilter DEFAULT_ATTR_FILTER = new AttributeFilter() { Set<String> mExcludes; @Override public String replace(String uri, String name, String value) { if (!ANDROID_URI.equals(uri)) { return value; } if (mExcludes == null) { mExcludes = new HashSet<String>(EXCLUDED_ATTRIBUTES.length); mExcludes.addAll(Arrays.asList(EXCLUDED_ATTRIBUTES)); } return mExcludes.contains(name) ? null : value; } }; /** * Copies all the attributes from oldElement to newNode. Uses the idMap to * transform the value of all attributes of Format.REFERENCE. If filter is * non-null, it's a filter that can rewrite the attribute string. */ protected static void addAttributes(INode newNode, IDragElement oldElement, Map<String, Pair<String, String>> idMap, AttributeFilter filter) { for (IDragAttribute attr : oldElement.getAttributes()) { String uri = attr.getUri(); String name = attr.getName(); String value = attr.getValue(); IAttributeInfo attrInfo = newNode.getAttributeInfo(uri, name); if (attrInfo != null) { if (attrInfo.getFormats().contains(IAttributeInfo.Format.REFERENCE)) { if (idMap.containsKey(value)) { value = idMap.get(value).getFirst(); } } } if (filter != null) { value = filter.replace(uri, name, value); } if (value != null && value.length() > 0) { newNode.setAttribute(uri, name, value); } } } /** * Adds all the children elements of oldElement to newNode, recursively. * Attributes are adjusted by calling addAttributes with idMap as necessary, * with no closure filter. */ protected static void addInnerElements(INode newNode, IDragElement oldElement, Map<String, Pair<String, String>> idMap) { for (IDragElement element : oldElement.getInnerElements()) { String fqcn = element.getFqcn(); INode childNode = newNode.appendChild(fqcn); addAttributes(childNode, element, idMap, null /* filter */); addInnerElements(childNode, element, idMap); } } /** * Insert the given elements into the given node at the given position * * @param targetNode the node to insert into * @param elements the elements to insert * @param createNewIds if true, generate new ids when there is a conflict * @param initialInsertPos index among targetnode's children which to insert the * children */ public static void insertAt(final INode targetNode, final IDragElement[] elements, final boolean createNewIds, final int initialInsertPos) { // Collect IDs from dropped elements and remap them to new IDs // if this is a copy or from a different canvas. final Map<String, Pair<String, String>> idMap = getDropIdMap(targetNode, elements, createNewIds); targetNode.editXml("Insert Elements", new INodeHandler() { @Override public void handle(@NonNull INode node) { // Now write the new elements. int insertPos = initialInsertPos; for (IDragElement element : elements) { String fqcn = element.getFqcn(); INode newChild = targetNode.insertChildAt(fqcn, insertPos); // insertPos==-1 means to insert at the end. Otherwise // increment the insertion position. if (insertPos >= 0) { insertPos++; } // Copy all the attributes, modifying them as needed. addAttributes(newChild, element, idMap, DEFAULT_ATTR_FILTER); addInnerElements(newChild, element, idMap); } } }); } // ---- Resizing ---- /** Creates a new {@link ResizeState} object to track resize state */ protected ResizeState createResizeState(INode layout, Object layoutView, INode node) { return new ResizeState(this, layout, layoutView, node); } @Override public DropFeedback onResizeBegin(@NonNull INode child, @NonNull INode parent, @Nullable SegmentType horizontalEdge, @Nullable SegmentType verticalEdge, @Nullable Object childView, @Nullable Object parentView) { ResizeState state = createResizeState(parent, parentView, child); state.horizontalEdgeType = horizontalEdge; state.verticalEdgeType = verticalEdge; // Compute preferred (wrap_content) size such that we can offer guidelines to // snap to the preferred size Map<INode, Rect> sizes = mRulesEngine.measureChildren(parent, new IClientRulesEngine.AttributeFilter() { @Override public String getAttribute(@NonNull INode node, @Nullable String namespace, @NonNull String localName) { // Change attributes to wrap_content if (ATTR_LAYOUT_WIDTH.equals(localName) && SdkConstants.NS_RESOURCES.equals(namespace)) { return VALUE_WRAP_CONTENT; } if (ATTR_LAYOUT_HEIGHT.equals(localName) && SdkConstants.NS_RESOURCES.equals(namespace)) { return VALUE_WRAP_CONTENT; } return null; } }); if (sizes != null) { state.wrapBounds = sizes.get(child); } return new DropFeedback(state, new IFeedbackPainter() { @Override public void paint(@NonNull IGraphics gc, @NonNull INode node, @NonNull DropFeedback feedback) { ResizeState resizeState = (ResizeState) feedback.userData; if (resizeState != null && resizeState.bounds != null) { paintResizeFeedback(gc, node, resizeState); } } }); } protected void paintResizeFeedback(IGraphics gc, INode node, ResizeState resizeState) { gc.useStyle(DrawingStyle.RESIZE_PREVIEW); Rect b = resizeState.bounds; gc.drawRect(b); if (resizeState.horizontalFillSegment != null) { gc.useStyle(DrawingStyle.GUIDELINE); Segment s = resizeState.horizontalFillSegment; gc.drawLine(s.from, s.at, s.to, s.at); } if (resizeState.verticalFillSegment != null) { gc.useStyle(DrawingStyle.GUIDELINE); Segment s = resizeState.verticalFillSegment; gc.drawLine(s.at, s.from, s.at, s.to); } if (resizeState.wrapBounds != null) { gc.useStyle(DrawingStyle.GUIDELINE); int wrapWidth = resizeState.wrapBounds.w; int wrapHeight = resizeState.wrapBounds.h; // Show the "wrap_content" guideline. // If we are showing both the wrap_width and wrap_height lines // then we show at most the rectangle formed by the two lines; // otherwise we show the entire width of the line if (resizeState.horizontalEdgeType != null) { int y = -1; switch (resizeState.horizontalEdgeType) { case TOP: y = b.y + b.h - wrapHeight; break; case BOTTOM: y = b.y + wrapHeight; break; default: assert false : resizeState.horizontalEdgeType; } if (resizeState.verticalEdgeType != null) { switch (resizeState.verticalEdgeType) { case LEFT: gc.drawLine(b.x + b.w - wrapWidth, y, b.x + b.w, y); break; case RIGHT: gc.drawLine(b.x, y, b.x + wrapWidth, y); break; default: assert false : resizeState.verticalEdgeType; } } else { gc.drawLine(b.x, y, b.x + b.w, y); } } if (resizeState.verticalEdgeType != null) { int x = -1; switch (resizeState.verticalEdgeType) { case LEFT: x = b.x + b.w - wrapWidth; break; case RIGHT: x = b.x + wrapWidth; break; default: assert false : resizeState.verticalEdgeType; } if (resizeState.horizontalEdgeType != null) { switch (resizeState.horizontalEdgeType) { case TOP: gc.drawLine(x, b.y + b.h - wrapHeight, x, b.y + b.h); break; case BOTTOM: gc.drawLine(x, b.y, x, b.y + wrapHeight); break; default: assert false : resizeState.horizontalEdgeType; } } else { gc.drawLine(x, b.y, x, b.y + b.h); } } } } /** * Returns the maximum number of pixels will be considered a "match" when snapping * resize or move positions to edges or other constraints * * @return the maximum number of pixels to consider for snapping */ public static final int getMaxMatchDistance() { // TODO - make constant once we're happy with the feel return 20; } @Override public void onResizeUpdate(@Nullable DropFeedback feedback, @NonNull INode child, @NonNull INode parent, @NonNull Rect newBounds, int modifierMask) { ResizeState state = (ResizeState) feedback.userData; state.bounds = newBounds; state.modifierMask = modifierMask; // Match on wrap bounds state.wrapWidth = state.wrapHeight = false; if (state.wrapBounds != null) { Rect b = state.wrapBounds; int maxMatchDistance = getMaxMatchDistance(); if (state.horizontalEdgeType != null) { if (Math.abs(newBounds.h - b.h) < maxMatchDistance) { state.wrapHeight = true; if (state.horizontalEdgeType == SegmentType.TOP) { newBounds.y += newBounds.h - b.h; } newBounds.h = b.h; } } if (state.verticalEdgeType != null) { if (Math.abs(newBounds.w - b.w) < maxMatchDistance) { state.wrapWidth = true; if (state.verticalEdgeType == SegmentType.LEFT) { newBounds.x += newBounds.w - b.w; } newBounds.w = b.w; } } } // Match on fill bounds state.horizontalFillSegment = null; state.fillHeight = false; if (state.horizontalEdgeType == SegmentType.BOTTOM && !state.wrapHeight) { Rect parentBounds = parent.getBounds(); state.horizontalFillSegment = new Segment(parentBounds.y2(), newBounds.x, newBounds.x2(), null /*node*/, null /*id*/, SegmentType.BOTTOM, MarginType.NO_MARGIN); if (Math.abs(newBounds.y2() - parentBounds.y2()) < getMaxMatchDistance()) { state.fillHeight = true; newBounds.h = parentBounds.y2() - newBounds.y; } } state.verticalFillSegment = null; state.fillWidth = false; if (state.verticalEdgeType == SegmentType.RIGHT && !state.wrapWidth) { Rect parentBounds = parent.getBounds(); state.verticalFillSegment = new Segment(parentBounds.x2(), newBounds.y, newBounds.y2(), null /*node*/, null /*id*/, SegmentType.RIGHT, MarginType.NO_MARGIN); if (Math.abs(newBounds.x2() - parentBounds.x2()) < getMaxMatchDistance()) { state.fillWidth = true; newBounds.w = parentBounds.x2() - newBounds.x; } } feedback.tooltip = getResizeUpdateMessage(state, child, parent, newBounds, state.horizontalEdgeType, state.verticalEdgeType); } @Override public void onResizeEnd(@Nullable DropFeedback feedback, @NonNull INode child, final @NonNull INode parent, final @NonNull Rect newBounds) { final Rect oldBounds = child.getBounds(); if (oldBounds.w != newBounds.w || oldBounds.h != newBounds.h) { final ResizeState state = (ResizeState) feedback.userData; child.editXml("Resize", new INodeHandler() { @Override public void handle(@NonNull INode n) { setNewSizeBounds(state, n, parent, oldBounds, newBounds, state.horizontalEdgeType, state.verticalEdgeType); } }); } } /** * Returns the message to display to the user during the resize operation * * @param resizeState the current resize state * @param child the child node being resized * @param parent the parent of the resized node * @param newBounds the new bounds to resize the child to, in pixels * @param horizontalEdge the horizontal edge being resized * @param verticalEdge the vertical edge being resized * @return the message to display for the current resize bounds */ protected String getResizeUpdateMessage(ResizeState resizeState, INode child, INode parent, Rect newBounds, SegmentType horizontalEdge, SegmentType verticalEdge) { String width = resizeState.getWidthAttribute(); String height = resizeState.getHeightAttribute(); if (horizontalEdge == null) { return width; } else if (verticalEdge == null) { return height; } else { // U+00D7: Unicode for multiplication sign return String.format("%s \u00D7 %s", width, height); } } /** * Performs the edit on the node to complete a resizing operation. The actual edit * part is pulled out such that subclasses can change/add to the edits and be part of * the same undo event * * @param resizeState the current resize state * @param node the child node being resized * @param layout the parent of the resized node * @param newBounds the new bounds to resize the child to, in pixels * @param horizontalEdge the horizontal edge being resized * @param verticalEdge the vertical edge being resized */ protected void setNewSizeBounds(ResizeState resizeState, INode node, INode layout, Rect oldBounds, Rect newBounds, SegmentType horizontalEdge, SegmentType verticalEdge) { if (verticalEdge != null && (newBounds.w != oldBounds.w || resizeState.wrapWidth || resizeState.fillWidth)) { node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, resizeState.getWidthAttribute()); } if (horizontalEdge != null && (newBounds.h != oldBounds.h || resizeState.wrapHeight || resizeState.fillHeight)) { node.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, resizeState.getHeightAttribute()); } } }