diff options
Diffstat (limited to 'eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/BaseLayoutRule.java')
-rw-r--r-- | eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/BaseLayoutRule.java | 878 |
1 files changed, 878 insertions, 0 deletions
diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/BaseLayoutRule.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/BaseLayoutRule.java new file mode 100644 index 000000000..df2c8f473 --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/common/layout/BaseLayoutRule.java @@ -0,0 +1,878 @@ +/* + * 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()); + } + } +} |