/*
* 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.ide.common.layout.LayoutConstants.ANDROID_URI;
import static com.android.ide.common.layout.LayoutConstants.ATTR_BASELINE_ALIGNED;
import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_GRAVITY;
import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_HEIGHT;
import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_WEIGHT;
import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_WIDTH;
import static com.android.ide.common.layout.LayoutConstants.ATTR_ORIENTATION;
import static com.android.ide.common.layout.LayoutConstants.ATTR_WEIGHT_SUM;
import static com.android.ide.common.layout.LayoutConstants.VALUE_HORIZONTAL;
import static com.android.ide.common.layout.LayoutConstants.VALUE_VERTICAL;
import com.android.ide.common.api.DrawingStyle;
import com.android.ide.common.api.DropFeedback;
import com.android.ide.common.api.IDragElement;
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.IViewMetadata;
import com.android.ide.common.api.IViewRule;
import com.android.ide.common.api.InsertType;
import com.android.ide.common.api.MenuAction;
import com.android.ide.common.api.Point;
import com.android.ide.common.api.Rect;
import com.android.ide.common.api.IViewMetadata.FillPreference;
import com.android.ide.common.api.MenuAction.OrderedChoices;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* An {@link IViewRule} for android.widget.LinearLayout and all its derived
* classes.
*/
public class LinearLayoutRule extends BaseLayoutRule {
private static final String ACTION_ORIENTATION = "_orientation"; //$NON-NLS-1$
private static final String ACTION_WEIGHT = "_weight"; //$NON-NLS-1$
private static final String ACTION_DISTRIBUTE = "_distribute"; //$NON-NLS-1$
private static final String ACTION_BASELINE = "_baseline"; //$NON-NLS-1$
private static final URL ICON_HORIZONTAL =
LinearLayoutRule.class.getResource("hlinear.png"); //$NON-NLS-1$
private static final URL ICON_VERTICAL =
LinearLayoutRule.class.getResource("vlinear.png"); //$NON-NLS-1$
private static final URL ICON_WEIGHTS =
LinearLayoutRule.class.getResource("weights.png"); //$NON-NLS-1$
private static final URL ICON_DISTRIBUTE =
LinearLayoutRule.class.getResource("distribute.png"); //$NON-NLS-1$
private static final URL ICON_BASELINE =
LinearLayoutRule.class.getResource("baseline.png"); //$NON-NLS-1$
/**
* Add an explicit Orientation toggle to the context menu.
*/
@Override
public List<MenuAction> getContextMenu(final INode selectedNode) {
if (supportsOrientation()) {
String current = getCurrentOrientation(selectedNode);
IMenuCallback onChange = new PropertyCallback(Collections.singletonList(selectedNode),
"Change LinearLayout Orientation",
ANDROID_URI, ATTR_ORIENTATION);
return concatenate(super.getContextMenu(selectedNode),
new MenuAction.Choices(ACTION_ORIENTATION, "Orientation", //$NON-NLS-1$
mapify(
"horizontal", "Horizontal", //$NON-NLS-1$
"vertical", "Vertical" //$NON-NLS-1$
),
current, onChange));
} else {
return super.getContextMenu(selectedNode);
}
}
/**
* Returns the current orientation, regardless of whether it has been defined in XML
*
* @param node The LinearLayout to look up the orientation for
* @return "horizontal" or "vertical" depending on the current orientation of the
* linear layout
*/
private String getCurrentOrientation(final INode node) {
String orientation = node.getStringAttr(ANDROID_URI, ATTR_ORIENTATION);
if (orientation == null || orientation.length() == 0) {
orientation = VALUE_HORIZONTAL;
}
return orientation;
}
/**
* Returns true if the given node represents a vertical linear layout.
* @param node the node to check layout orientation for
* @return true if the layout is in vertical mode, otherwise false
*/
protected boolean isVertical(INode node) {
// Horizontal is the default, so if no value is specified it is horizontal.
return VALUE_VERTICAL.equals(node.getStringAttr(ANDROID_URI,
ATTR_ORIENTATION));
}
/**
* Returns true if this LinearLayout supports switching orientation.
*
* @return true if this layout supports orientations
*/
protected boolean supportsOrientation() {
return true;
}
@Override
public void addLayoutActions(List<MenuAction> actions, final INode parentNode,
final List<? extends INode> children) {
super.addLayoutActions(actions, parentNode, children);
if (supportsOrientation()) {
OrderedChoices action = MenuAction.createChoices(
ACTION_ORIENTATION, "Orientation", //$NON-NLS-1$
null,
new PropertyCallback(Collections.singletonList(parentNode),
"Change LinearLayout Orientation",
ANDROID_URI, ATTR_ORIENTATION),
Arrays.<String>asList("Set Horizontal Orientation", "Set Vertical Orientation"),
Arrays.<URL>asList(ICON_HORIZONTAL, ICON_VERTICAL),
Arrays.<String>asList("horizontal", "vertical"),
getCurrentOrientation(parentNode),
null /* icon */,
-10
);
action.setRadio(true);
actions.add(action);
}
if (!isVertical(parentNode)) {
String current = parentNode.getStringAttr(ANDROID_URI, ATTR_BASELINE_ALIGNED);
boolean isAligned = current == null || Boolean.valueOf(current);
actions.add(MenuAction.createToggle(null, "Toggle Baseline Alignment",
isAligned,
new PropertyCallback(Collections.singletonList(parentNode),
"Change Baseline Alignment",
ANDROID_URI, ATTR_BASELINE_ALIGNED), // TODO: Also set index?
ICON_BASELINE, 38));
}
// Gravity
if (children != null && children.size() > 0) {
actions.add(MenuAction.createSeparator(35));
// Margins
actions.add(createMarginAction(parentNode, children));
// Gravity
actions.add(createGravityAction(children, ATTR_LAYOUT_GRAVITY));
// Weights
IMenuCallback actionCallback = new IMenuCallback() {
public void action(final MenuAction action, final String valueId,
final Boolean newValue) {
parentNode.editXml("Change Weight", new INodeHandler() {
public void handle(INode n) {
if (action.getId().equals(ACTION_WEIGHT)) {
String weight =
children.get(0).getStringAttr(ANDROID_URI, ATTR_LAYOUT_WEIGHT);
if (weight == null || weight.length() == 0) {
weight = "0.0"; //$NON-NLS-1$
}
weight = mRulesEngine.displayInput("Enter Weight Value:", weight,
null);
if (weight != null) {
for (INode child : children) {
child.setAttribute(ANDROID_URI,
ATTR_LAYOUT_WEIGHT, weight);
}
}
} else if (action.getId().equals(ACTION_DISTRIBUTE)) {
// Any XML to get weight sum?
String weightSum = parentNode.getStringAttr(ANDROID_URI,
ATTR_WEIGHT_SUM);
double sum = -1.0;
if (weightSum != null) {
// Distribute
try {
sum = Double.parseDouble(weightSum);
} catch (NumberFormatException nfe) {
// Just keep using the default
}
}
INode[] targets = parentNode.getChildren();
int numTargets = targets.length;
double share;
if (sum <= 0.0) {
// The sum will be computed from the children, so just
// use arbitrary amount
share = 1.0;
} else {
share = sum / numTargets;
}
String value;
if (share != (int) share) {
value = String.format("%.2f", (float) share); //$NON-NLS-1$
} else {
value = Integer.toString((int) share);
}
for (INode target : targets) {
target.setAttribute(ANDROID_URI, ATTR_LAYOUT_WEIGHT, value);
}
} else {
assert action.getId().equals(ACTION_BASELINE);
}
}
});
}
};
actions.add(MenuAction.createSeparator(50));
actions.add(MenuAction.createAction(ACTION_DISTRIBUTE, "Distribute Weights Evenly",
null, actionCallback, ICON_DISTRIBUTE, 60));
actions.add(MenuAction.createAction(ACTION_WEIGHT, "Change Layout Weight", null,
actionCallback, ICON_WEIGHTS, 70));
}
}
// ==== Drag'n'drop support ====
@Override
public DropFeedback onDropEnter(final INode targetNode, final IDragElement[] elements) {
if (elements.length == 0) {
return null;
}
Rect bn = targetNode.getBounds();
if (!bn.isValid()) {
return null;
}
boolean isVertical = isVertical(targetNode);
// Prepare a list of insertion points: X coords for horizontal, Y for
// vertical.
List<MatchPos> indexes = new ArrayList<MatchPos>();
int last = isVertical ? bn.y : bn.x;
int pos = 0;
boolean lastDragged = false;
int selfPos = -1;
for (INode it : targetNode.getChildren()) {
Rect bc = it.getBounds();
if (bc.isValid()) {
// First see if this node looks like it's the same as one of the
// *dragged* bounds
boolean isDragged = false;
for (IDragElement element : elements) {
// This tries to determine if an INode corresponds to an
// IDragElement, by comparing their bounds.
if (bc.equals(element.getBounds())) {
isDragged = true;
}
}
// We don't want to insert drag positions before or after the
// element that is itself being dragged. However, we -do- want
// to insert a match position here, at the center, such that
// when you drag near its current position we show a match right
// where it's already positioned.
if (isDragged) {
int v = isVertical ? bc.y + (bc.h / 2) : bc.x + (bc.w / 2);
selfPos = pos;
indexes.add(new MatchPos(v, pos++));
} else if (lastDragged) {
// Even though we don't want to insert a match below, we
// need to increment the index counter such that subsequent
// lines know their correct index in the child list.
pos++;
} else {
// Add an insertion point between the last point and the
// start of this child
int v = isVertical ? bc.y : bc.x;
v = (last + v) / 2;
indexes.add(new MatchPos(v, pos++));
}
last = isVertical ? (bc.y + bc.h) : (bc.x + bc.w);
lastDragged = isDragged;
} else {
// We still have to count this position even if it has no bounds, or
// subsequent children will be inserted at the wrong place
pos++;
}
}
// Finally add an insert position after all the children - unless of
// course we happened to be dragging the last element
if (!lastDragged) {
int v = last + 1;
indexes.add(new MatchPos(v, pos));
}
int posCount = targetNode.getChildren().length + 1;
return new DropFeedback(new LinearDropData(indexes, posCount, isVertical, selfPos),
new IFeedbackPainter() {
public void paint(IGraphics gc, INode node, DropFeedback feedback) {
// Paint callback for the LinearLayout. This is called
// by the canvas when a draw is needed.
drawFeedback(gc, node, elements, feedback);
}
});
}
void drawFeedback(IGraphics gc, INode node, IDragElement[] elements, DropFeedback feedback) {
Rect b = node.getBounds();
if (!b.isValid()) {
return;
}
// Highlight the receiver
gc.useStyle(DrawingStyle.DROP_RECIPIENT);
gc.drawRect(b);
gc.useStyle(DrawingStyle.DROP_ZONE);
LinearDropData data = (LinearDropData) feedback.userData;
boolean isVertical = data.isVertical();
int selfPos = data.getSelfPos();
for (MatchPos it : data.getIndexes()) {
int i = it.getDistance();
int pos = it.getPosition();
// Don't show insert drop zones for "self"-index since that one goes
// right through the center of the widget rather than in a sibling
// position
if (pos != selfPos) {
if (isVertical) {
// draw horizontal lines
gc.drawLine(b.x, i, b.x + b.w, i);
} else {
// draw vertical lines
gc.drawLine(i, b.y, i, b.y + b.h);
}
}
}
Integer currX = data.getCurrX();
Integer currY = data.getCurrY();
if (currX != null && currY != null) {
gc.useStyle(DrawingStyle.DROP_ZONE_ACTIVE);
int x = currX;
int y = currY;
Rect be = elements[0].getBounds();
// Draw a clear line at the closest drop zone (unless we're over the
// dragged element itself)
if (data.getInsertPos() != selfPos || selfPos == -1) {
gc.useStyle(DrawingStyle.DROP_PREVIEW);
if (data.getWidth() != null) {
int width = data.getWidth();
int fromX = x - width / 2;
int toX = x + width / 2;
gc.drawLine(fromX, y, toX, y);
} else if (data.getHeight() != null) {
int height = data.getHeight();
int fromY = y - height / 2;
int toY = y + height / 2;
gc.drawLine(x, fromY, x, toY);
}
}
if (be.isValid()) {
boolean isLast = data.isLastPosition();
// At least the first element has a bound. Draw rectangles for
// all dropped elements with valid bounds, offset at the drop
// point.
int offsetX;
int offsetY;
if (isVertical) {
offsetX = b.x - be.x;
offsetY = currY - be.y - (isLast ? 0 : (be.h / 2));
} else {
offsetX = currX - be.x - (isLast ? 0 : (be.w / 2));
offsetY = b.y - be.y;
}
gc.useStyle(DrawingStyle.DROP_PREVIEW);
for (IDragElement element : elements) {
Rect bounds = element.getBounds();
if (bounds.isValid() && (bounds.w > b.w || bounds.h > b.h) &&
node.getChildren().length == 0) {
// The bounds of the child does not fully fit inside the target.
// Limit the bounds to the layout bounds (but only when there
// are no children, since otherwise positioning around the existing
// children gets difficult)
final int px, py, pw, ph;
if (bounds.w > b.w) {
px = b.x;
pw = b.w;
} else {
px = bounds.x + offsetX;
pw = bounds.w;
}
if (bounds.h > b.h) {
py = b.y;
ph = b.h;
} else {
py = bounds.y + offsetY;
ph = bounds.h;
}
Rect within = new Rect(px, py, pw, ph);
gc.drawRect(within);
} else {
drawElement(gc, element, offsetX, offsetY);
}
}
}
}
}
@Override
public DropFeedback onDropMove(INode targetNode, IDragElement[] elements,
DropFeedback feedback, Point p) {
Rect b = targetNode.getBounds();
if (!b.isValid()) {
return feedback;
}
LinearDropData data = (LinearDropData) feedback.userData;
boolean isVertical = data.isVertical();
int bestDist = Integer.MAX_VALUE;
int bestIndex = Integer.MIN_VALUE;
Integer bestPos = null;
for (MatchPos index : data.getIndexes()) {
int i = index.getDistance();
int pos = index.getPosition();
int dist = (isVertical ? p.y : p.x) - i;
if (dist < 0)
dist = -dist;
if (dist < bestDist) {
bestDist = dist;
bestIndex = i;
bestPos = pos;
if (bestDist <= 0)
break;
}
}
if (bestIndex != Integer.MIN_VALUE) {
Integer oldX = data.getCurrX();
Integer oldY = data.getCurrY();
if (isVertical) {
data.setCurrX(b.x + b.w / 2);
data.setCurrY(bestIndex);
data.setWidth(b.w);
data.setHeight(null);
} else {
data.setCurrX(bestIndex);
data.setCurrY(b.y + b.h / 2);
data.setWidth(null);
data.setHeight(b.h);
}
data.setInsertPos(bestPos);
feedback.requestPaint = !equals(oldX, data.getCurrX())
|| !equals(oldY, data.getCurrY());
}
return feedback;
}
private static boolean equals(Integer i1, Integer i2) {
if (i1 == i2) {
return true;
} else if (i1 != null) {
return i1.equals(i2);
} else {
// We know i2 != null
return i2.equals(i1);
}
}
@Override
public void onDropLeave(INode targetNode, IDragElement[] elements, DropFeedback feedback) {
// ignore
}
@Override
public void onDropped(final INode targetNode, final IDragElement[] elements,
final DropFeedback feedback, final Point p) {
LinearDropData data = (LinearDropData) feedback.userData;
final int initialInsertPos = data.getInsertPos();
insertAt(targetNode, elements, feedback.isCopy || !feedback.sameCanvas, initialInsertPos);
}
@Override
public void onChildInserted(INode node, INode parent, InsertType insertType) {
// Attempt to set fill-properties on newly added views such that for example,
// in a vertical layout, a text field defaults to filling horizontally, but not
// vertically.
String fqcn = node.getFqcn();
IViewMetadata metadata = mRulesEngine.getMetadata(fqcn);
if (metadata != null) {
boolean vertical = isVertical(parent);
FillPreference fill = metadata.getFillPreference();
String fillParent = getFillParentValueName();
if (fill.fillHorizontally(vertical)) {
node.setAttribute(ANDROID_URI, ATTR_LAYOUT_WIDTH, fillParent);
}
if (fill.fillVertically(vertical)) {
node.setAttribute(ANDROID_URI, ATTR_LAYOUT_HEIGHT, fillParent);
}
}
}
/** A possible match position */
private class MatchPos {
/** The pixel distance */
private int mDistance;
/** The position among siblings */
private int mPosition;
public MatchPos(int distance, int position) {
this.mDistance = distance;
this.mPosition = position;
}
@Override
public String toString() {
return "MatchPos [distance=" + mDistance //$NON-NLS-1$
+ ", position=" + mPosition //$NON-NLS-1$
+ "]"; //$NON-NLS-1$
}
private int getDistance() {
return mDistance;
}
private int getPosition() {
return mPosition;
}
}
private class LinearDropData {
/** Vertical layout? */
private final boolean mVertical;
/** Insert points (pixels + index) */
private final List<MatchPos> mIndexes;
/** Number of insert positions in the target node */
private final int mNumPositions;
/** Current marker X position */
private Integer mCurrX;
/** Current marker Y position */
private Integer mCurrY;
/** Position of the dragged element in this layout (or
-1 if the dragged element is from elsewhere) */
private final int mSelfPos;
/** Current drop insert index (-1 for "at the end") */
private int mInsertPos = -1;
/** width of match line if it's a horizontal one */
private Integer mWidth;
/** height of match line if it's a vertical one */
private Integer mHeight;
public LinearDropData(List<MatchPos> indexes, int numPositions,
boolean isVertical, int selfPos) {
this.mIndexes = indexes;
this.mNumPositions = numPositions;
this.mVertical = isVertical;
this.mSelfPos = selfPos;
}
@Override
public String toString() {
return "LinearDropData [currX=" + mCurrX //$NON-NLS-1$
+ ", currY=" + mCurrY //$NON-NLS-1$
+ ", height=" + mHeight //$NON-NLS-1$
+ ", indexes=" + mIndexes //$NON-NLS-1$
+ ", insertPos=" + mInsertPos //$NON-NLS-1$
+ ", isVertical=" + mVertical //$NON-NLS-1$
+ ", selfPos=" + mSelfPos //$NON-NLS-1$
+ ", width=" + mWidth //$NON-NLS-1$
+ "]"; //$NON-NLS-1$
}
private boolean isVertical() {
return mVertical;
}
private void setCurrX(Integer currX) {
this.mCurrX = currX;
}
private Integer getCurrX() {
return mCurrX;
}
private void setCurrY(Integer currY) {
this.mCurrY = currY;
}
private Integer getCurrY() {
return mCurrY;
}
private int getSelfPos() {
return mSelfPos;
}
private void setInsertPos(int insertPos) {
this.mInsertPos = insertPos;
}
private int getInsertPos() {
return mInsertPos;
}
private List<MatchPos> getIndexes() {
return mIndexes;
}
private void setWidth(Integer width) {
this.mWidth = width;
}
private Integer getWidth() {
return mWidth;
}
private void setHeight(Integer height) {
this.mHeight = height;
}
private Integer getHeight() {
return mHeight;
}
/**
* Returns true if we are inserting into the last position
*
* @return true if we are inserting into the last position
*/
public boolean isLastPosition() {
return mInsertPos == mNumPositions - 1;
}
}
}