zul/src/main/java/org/zkoss/zul/Radiogroup.java
/* Radiogroup.java
Purpose:
Description:
History:
Fri Jun 17 09:20:41 2005, Created by tomyeh
Copyright (C) 2005 Potix Corporation. All Rights Reserved.
{{IS_RIGHT
This program is distributed under LGPL Version 2.1 in the hope that
it will be useful, but WITHOUT ANY WARRANTY.
}}IS_RIGHT
*/
package org.zkoss.zul;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.zkoss.lang.Classes;
import org.zkoss.lang.Exceptions;
import org.zkoss.lang.MutableInteger;
import org.zkoss.lang.Objects;
import org.zkoss.lang.Strings;
import org.zkoss.xel.VariableResolver;
import org.zkoss.zk.ui.Component;
import org.zkoss.zk.ui.Page;
import org.zkoss.zk.ui.UiException;
import org.zkoss.zk.ui.WrongValueException;
import org.zkoss.zk.ui.event.CheckEvent;
import org.zkoss.zk.ui.event.Event;
import org.zkoss.zk.ui.event.Events;
import org.zkoss.zk.ui.ext.Disable;
import org.zkoss.zk.ui.sys.ShadowElementsCtrl;
import org.zkoss.zk.ui.util.ComponentCloneListener;
import org.zkoss.zk.ui.util.ForEachStatus;
import org.zkoss.zk.ui.util.Template;
import org.zkoss.zul.event.ListDataEvent;
import org.zkoss.zul.event.ListDataListener;
import org.zkoss.zul.event.ZulEvents;
import org.zkoss.zul.ext.Selectable;
import org.zkoss.zul.impl.XulElement;
/**
* A radio group.
*
* <p>Note: To support the versatile layout, a radio group accepts any kind of
* children, including {@link Radio}. On the other hand, the parent of
* a radio, if any, must be a radio group.
*
* @author tomyeh
*/
public class Radiogroup extends XulElement implements Disable {
private static final Logger log = LoggerFactory.getLogger(Radiogroup.class);
private static final String ZUL_RADIOGROUP_ON_INITRENDER = "zul.Radiogroup.ON_INITRENDER";
private String _orient = "horizontal";
/** The name of all child radio buttons. */
private String _name;
/** A list of external radio ({@link Radio}) components. */
private List<Radio> _externs;
private int _jsel = -1;
private ListModel<?> _model;
private RadioRenderer<?> _renderer;
private transient ListDataListener _dataListener;
private boolean _disabled;
static {
addClientEvent(Radiogroup.class, Events.ON_CHECK, CE_IMPORTANT | CE_REPEAT_IGNORE);
}
public Radiogroup() {
_name = genGroupName();
}
/** Returns the orient.
* <p>Default: "horizontal".
*/
public String getOrient() {
return _orient;
}
/** Sets the orient.
* @param orient either "horizontal" or "vertical".
*/
public void setOrient(String orient) throws WrongValueException {
if (!"horizontal".equals(orient) && !"vertical".equals(orient))
throw new WrongValueException("orient cannot be " + orient);
if (!Objects.equals(_orient, orient)) {
_orient = orient;
smartUpdate("orient", _orient);
}
}
/** Returns a readonly list of {@link Radio}.
* Note: any update to the list won't affect the state of this radio group.
* @since 5.0.4
*/
public List<Radio> getItems() {
//FUTURE: the algorithm is stupid and it shall be similar to Listbox
//however, it is OK since there won't be many radio buttons in a group
final List<Radio> items = new ArrayList<Radio>();
getItems0(this, items);
if (_externs != null)
for (Radio radio : _externs) {
if (!isRedudant(radio))
items.add(radio);
}
return items;
}
private static void getItems0(Component comp, List<Radio> items) {
for (Component child : comp.getChildren()) {
if (child instanceof Radio)
items.add((Radio) child);
else if (!(child instanceof Radiogroup)) //skip nested radiogroup
getItems0(child, items);
}
}
/** Returns the radio button at the specified index.
*/
public Radio getItemAtIndex(int index) {
if (index < 0)
throw new IndexOutOfBoundsException("Wrong index: " + index);
final MutableInteger cur = new MutableInteger(0);
Radio radio = getAt(this, cur, index);
if (radio != null)
return radio;
if (_externs != null)
for (Radio r : _externs) {
if (!isRedudant(r) && cur.value++ == index)
return r;
}
throw new IndexOutOfBoundsException(index + " out of 0.." + (cur.value - 1));
}
private static Radio getAt(Component comp, MutableInteger cur, int index) {
for (Iterator it = comp.getChildren().iterator(); it.hasNext();) {
final Component child = (Component) it.next();
if (child instanceof Radio) {
if (cur.value++ == index)
return (Radio) child;
} else if (!(child instanceof Radiogroup)) { //skip nested radiogroup
Radio r = getAt(child, cur, index);
if (r != null)
return r;
}
}
return null;
}
private boolean isRedudant(Radio radio) {
for (Component p = radio; (p = p.getParent()) != null;)
if (p instanceof Radiogroup)
return p == this;
return false;
}
/** Returns the number of radio buttons in this group.
*/
public int getItemCount() {
int sum = countItems(this);
if (_externs != null)
for (Radio radio : _externs) {
if (!isRedudant(radio))
++sum;
}
return sum;
}
private static int countItems(Component comp) {
int sum = 0;
for (Iterator it = comp.getChildren().iterator(); it.hasNext();) {
final Component child = (Component) it.next();
if (child instanceof Radio)
++sum;
else if (!(child instanceof Radiogroup)) //skip nested radiogroup
sum += countItems(child);
}
return sum;
}
/** Returns the index of the selected radio button (-1 if no one is selected).
*
* Note: The index of the external radio button could be unintuitive
* since radio group always count descendant radio before external radio.
*/
public int getSelectedIndex() {
return _jsel;
}
/** Deselects all of the currently selected radio button and selects
* the radio button with the given index.
*/
public void setSelectedIndex(int jsel) {
if (jsel < 0)
jsel = -1;
if (_jsel != jsel) {
if (jsel < 0) {
Radio r = getSelectedItem();
if (r != null)
r.setSelected(false);
} else {
getItemAtIndex(jsel).setSelected(true);
}
}
}
/** Returns the selected radio button.
*/
public Radio getSelectedItem() {
return _jsel >= 0 ? getItemAtIndex(_jsel) : null;
}
/** Deselects all of the currently selected radio buttons and selects
* the given radio button.
*/
public void setSelectedItem(Radio item) {
if (item == null) {
setSelectedIndex(-1);
} else {
if (item.getRadiogroup() != this)
throw new UiException("Not a child: " + item);
item.setSelected(true);
}
}
/** Appends a radio button.
*/
public Radio appendItem(String label, String value) {
final Radio item = new Radio();
item.setLabel(label);
item.setValue(value);
item.setParent(this);
return item;
}
/** Removes the child radio button in the radio group at the given index.
* @return the removed radio button.
*/
public Radio removeItemAt(int index) {
final Radio item = getItemAtIndex(index);
if (item != null && !removeExternal(item)) {
final Component p = item.getParent();
if (p != null)
p.removeChild(item);
}
return item;
}
/** Returns the name of this group of radio buttons.
* All child radio buttons shared the same name ({@link Radio#getName}).
* <p>Default: automatically generated a unique name
* <p>Don't use this method if your application is purely based
* on ZK's event-driven model.
*/
public String getName() {
return _name;
}
/** Sets the name of this group of radio buttons.
* All child radio buttons shared the same name ({@link Radio#getName}).
* <p>Don't use this method if your application is purely based
* on ZK's event-driven model.
*/
public void setName(String name) {
if (name != null && name.length() == 0)
name = null;
if (!Objects.equals(_name, name)) {
_name = name;
smartUpdate("name", _name);
}
}
//utilities for radio//
/** Called when a radio is added to this group.
*/
/*package*/ void fixOnAdd(Radio child, boolean external) {
if (external && _jsel >= 0 && child.isSelected()) {
child.setSelected(false); //it will call fixSelectedIndex
} else {
fixSelectedIndex();
}
}
/** Called when a radio is removed from this group.
*/
/*package*/ void fixOnRemove(Radio child) {
if (child.isSelected()) {
_jsel = -1;
} else if (_jsel > 0) { //excluding 0
fixSelectedIndex();
}
}
/** Fix the selected index, _jsel, assuming there are no selected one
* before (and excludes) j-the radio button.
*/
/*package*/ void fixSelectedIndex() {
final MutableInteger cur = new MutableInteger(0);
_jsel = fixSelIndex(this, cur);
if (_jsel < 0 && _externs != null)
for (Radio radio : _externs) {
if (!isRedudant(radio)) {
if (radio.isSelected()) {
_jsel = cur.value;
break; //found
}
++cur.value;
}
}
}
private static int fixSelIndex(Component comp, MutableInteger cur) {
for (Iterator it = comp.getChildren().iterator(); it.hasNext();) {
final Component child = (Component) it.next();
if (child instanceof Radio) {
if (((Radio) child).isSelected())
return cur.value;
++cur.value;
} else if (!(child instanceof Radiogroup)) { //skip nested radiogroup
int jsel = fixSelIndex(child, cur);
if (jsel >= 0)
return jsel;
}
}
return -1;
}
/** Adds an external radio. An external radio is a radio that is NOT a
* descendant of the radio group.
*/
/*package*/ void addExternal(Radio radio) {
if (_externs == null)
_externs = new LinkedList<Radio>();
_externs.add(radio);
if (_disabled)
radio.setDisabled(true);
if (!isRedudant(radio))
fixOnAdd(radio, true);
}
/** Removes an external radio.
*/
/*package*/ boolean removeExternal(Radio radio) {
if (_externs != null && _externs.remove(radio)) {
if (!isRedudant(radio))
fixOnRemove(radio);
return true;
}
return false;
}
/** Generates the group name for child radio buttons.
*/
private String genGroupName() {
return Strings.encode(new StringBuffer(16).append("_pg"), System.identityHashCode(this)).toString();
}
/** Returns whether it is disabled.
* <p>Default: false.
* @since 9.6.0
*/
public boolean isDisabled() {
return _disabled;
}
/** Sets whether the radiogroup is disabled.
* All the radios belong to this radiogroup will be set to disabled or not disabled as well.
* Notice: Once a radio is added to a disabled radiogroup (including external radio),
* the radio will be set to disabled too.
* @param disabled whether the radiogroup is disabled
* @since 9.6.0
*/
public void setDisabled(boolean disabled) {
if (this._disabled != disabled) {
this._disabled = disabled;
List<Radio> items = getItems();
for (Radio radio : items) {
radio.setDisabled(disabled);
}
}
}
protected void renderProperties(org.zkoss.zk.ui.sys.ContentRenderer renderer) throws java.io.IOException {
super.renderProperties(renderer);
if (_name != null)
render(renderer, "name", _name);
if (!"horizontal".equals(_orient))
render(renderer, "orient", _orient);
if (isDisabled()) {
render(renderer, "disabled", true);
}
if (_jsel >= 0) {
render(renderer, "selectedIndex", _jsel);
}
}
//-- ListModel dependent codes --//
/** Returns the list model associated with this radiogroup, or null
* if this radiogroup is not associated with any list data model.
* @since 6.0.0
*/
@SuppressWarnings("unchecked")
public <T> ListModel<T> getModel() {
return (ListModel) _model;
}
/** Sets the list model associated with this radiogroup.
* If a non-null model is assigned, no matter whether it is the same as
* the previous, it will always cause re-render.
*
* @param model the list model to associate, or null to dis-associate
* any previous model.
* @exception UiException if failed to initialize with the model
* @since 6.0.0
*/
public void setModel(ListModel<?> model) {
if (model != null) {
if (!(model instanceof Selectable))
throw new UiException(model.getClass() + " must implement " + Selectable.class);
if (_model != model) {
if (_model != null) {
_model.removeListDataListener(_dataListener);
//2012/1/11 TonyQ:Here we only clear children but not external radioss.
} else if (!getChildren().isEmpty())
getChildren().clear();
_model = model;
initDataListener();
}
postOnInitRender(null);
//Since user might setModel and setRender separately or repeatedly,
//we don't handle it right now until the event processing phase
//such that we won't render the same set of data twice
//--
//For better performance, we shall load the first few row now
//(to save a round trip)
} else if (_model != null) {
_model.removeListDataListener(_dataListener);
_model = null;
if (!getChildren().isEmpty())
getChildren().clear();
}
}
private void initDataListener() {
if (_dataListener == null) {
_dataListener = new ListDataListener() {
public void onChange(ListDataEvent event) {
// Bug B30-1906748.zul
switch (event.getType()) {
case ListDataEvent.SELECTION_CHANGED:
doSelectionChanged();
return; //nothing changed so need to rerender
case ListDataEvent.MULTIPLE_CHANGED:
return; //nothing to do
}
postOnInitRender(null);
}
};
}
_model.addListDataListener(_dataListener);
}
private void doSelectionChanged() {
final Selectable<Object> smodel = getSelectableModel();
if (smodel.isSelectionEmpty()) {
if (_jsel >= 0)
setSelectedItem(null);
return;
}
if (_jsel >= 0 && smodel.isSelected(_model.getElementAt(_jsel)))
return; //nothing changed
int j = 0;
for (final Radio item : getItems()) {
if (smodel.isSelected(_model.getElementAt(j++))) {
setSelectedItem(item);
return;
}
}
setSelectedItem(null); //something wrong but be self-protected
}
@SuppressWarnings("unchecked")
private Selectable<Object> getSelectableModel() {
return (Selectable<Object>) _model;
}
private void postOnInitRender(String idx) {
//20080724, Henri Chen: optimize to avoid postOnInitRender twice
if (getAttribute(ZUL_RADIOGROUP_ON_INITRENDER) == null) {
//Bug #2010389
setAttribute(ZUL_RADIOGROUP_ON_INITRENDER, Boolean.TRUE); //flag syncModel
Events.postEvent("onInitRender", this, idx);
}
}
/**
* For model rendering
* @param data
*/
@SuppressWarnings("rawtypes")
public void onInitRender(Event data) {
//Bug #2010389
removeAttribute(ZUL_RADIOGROUP_ON_INITRENDER); //clear syncModel flag
final Renderer renderer = new Renderer();
final ListModel subset = _model;
try {
if (!getChildren().isEmpty())
getChildren().clear();
int pgsz = subset.getSize(), ofs = 0;
for (int j = 0; j < pgsz; ++j) {
Radio item = new Radio();
item.applyProperties();
item.setParent(this);
final int index = j + ofs;
final Object value = subset.getElementAt(index);
renderer.render(item, value, index);
Object v = item.getAttribute(Attributes.MODEL_RENDERAS);
if (v != null) //a new item is created to replace the existent one
item = (Radio) v;
if (getSelectableModel().isSelected(value))
setSelectedItem(item);
}
} catch (Throwable ex) {
renderer.doCatch(ex);
} finally {
renderer.doFinally();
}
Events.postEvent("onInitRenderLater", this, null); // notify databinding load-when.
Events.postEvent(ZulEvents.ON_AFTER_RENDER, this, null); // notify the combobox when items have been rendered.
}
/**
* We need to sync model selection to keep select status .
* @see Radio#service(org.zkoss.zk.au.AuRequest, boolean)
*/
/*package*/ void syncSelectionToModel() {
if (_model != null) {
List<Object> selObjs = new ArrayList<Object>();
if (_jsel >= 0)
selObjs.add(_model.getElementAt(_jsel));
getSelectableModel().setSelection(selObjs);
}
}
/** Returns the renderer used to render items.
*/
@SuppressWarnings("unchecked")
private <T> RadioRenderer<T> getRealRenderer() {
return _renderer != null ? (RadioRenderer<T>) _renderer : _defRend;
}
/** Returns the renderer to render each radio, or null if the default
* renderer is used.
* @since 6.0.0
*/
@SuppressWarnings("unchecked")
public <T> RadioRenderer<T> getRadioRenderer() {
return (RadioRenderer<T>) _renderer;
}
/** Sets the renderer which is used to render each row
* if {@link #getModel} is not null.
*
* <p>Note: changing a render will not cause the radiogroup to re-render.
* If you want it to re-render, you could assign the same model again
* (i.e., setModel(getModel())), or fire an {@link ListDataEvent} event.
*
* @param renderer the renderer, or null to use the default.
* @exception UiException if failed to initialize with the model
* @since 6.0.0
*/
public void setRadioRenderer(RadioRenderer<?> renderer) {
_renderer = renderer;
}
/** Sets the renderer by use of a class name.
* It creates an instance automatically.
*@since 6.0.0
*/
public void setRadioRenderer(String clsnm) throws ClassNotFoundException, NoSuchMethodException,
IllegalAccessException, InstantiationException, java.lang.reflect.InvocationTargetException {
if (clsnm != null)
setRadioRenderer((RadioRenderer) Classes.newInstanceByThread(clsnm));
}
/**
* The default Renderer for model rendering.
*/
@SuppressWarnings("rawtypes")
private static final RadioRenderer _defRend = new RadioRenderer() {
public void render(final Radio item, final Object data, final int index) throws Exception {
final Radiogroup cb = (Radiogroup) item.getParent();
final Template tm = cb.getTemplate("model");
if (tm == null) {
item.setLabel(Objects.toString(data));
item.setValue(data);
} else {
final Component[] items = ShadowElementsCtrl
.filterOutShadows(tm.create(item.getParent(), item, new VariableResolver() {
public Object resolveVariable(String name) {
if ("each".equals(name)) {
return data;
} else if ("forEachStatus".equals(name)) {
return new ForEachStatus() {
public ForEachStatus getPrevious() {
return null;
}
public Object getEach() {
return getCurrent();
}
public int getIndex() {
return index;
}
public Integer getBegin() {
return 0;
}
public Integer getEnd() {
return cb.getModel().getSize();
}
public Object getCurrent() {
return data;
}
public boolean isFirst() {
return getCount() == 1;
}
public boolean isLast() {
return getIndex() + 1 == getEnd();
}
public Integer getStep() {
return null;
}
public int getCount() {
return getIndex() + 1;
}
};
} else {
return null;
}
}
}, null));
if (items.length != 1)
throw new UiException("The model template must have exactly one item, not " + items.length);
final Radio nci = (Radio) items[0];
if (nci.getValue() == null) //template might set it
nci.setValue(data);
item.setAttribute(Attributes.MODEL_RENDERAS, nci);
//indicate a new item is created to replace the existent one
item.detach();
}
}
};
/** Used to render Radio if _model is specified. */
private class Renderer implements java.io.Serializable {
@SuppressWarnings("rawtypes")
private final RadioRenderer _renderer;
private boolean _rendered, _ctrled;
private Renderer() {
_renderer = getRealRenderer();
}
@SuppressWarnings("unchecked")
private void render(Radio item, Object value, int index) throws Throwable {
if (!_rendered && (_renderer instanceof RendererCtrl)) {
((RendererCtrl) _renderer).doTry();
_ctrled = true;
}
try {
_renderer.render(item, value, index);
} catch (Throwable ex) {
try {
item.setLabel(Exceptions.getMessage(ex));
} catch (Throwable t) {
log.error("", t);
}
throw ex;
}
_rendered = true;
}
private void doCatch(Throwable ex) {
if (_ctrled) {
try {
((RendererCtrl) _renderer).doCatch(ex);
} catch (Throwable t) {
throw UiException.Aide.wrap(t);
}
} else {
throw UiException.Aide.wrap(ex);
}
}
private void doFinally() {
if (_ctrled)
((RendererCtrl) _renderer).doFinally();
}
}
//Cloneable//
public Object clone() {
final Radiogroup clone = (Radiogroup) super.clone();
fixClone(clone);
if (clone._model != null) {
if (clone._model instanceof ComponentCloneListener) {
final ListModel model = (ListModel) ((ComponentCloneListener) clone._model).willClone(clone);
if (model != null)
clone._model = model;
}
clone._dataListener = null;
clone.initDataListener();
}
return clone;
}
private static void fixClone(Radiogroup clone) {
if (clone._name.startsWith("_pg"))
clone._name = clone.genGroupName();
}
// Serializable//
private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException {
s.defaultReadObject();
if (_model != null) {
initDataListener();
}
}
//-- ComponentCtrl --//
/** Processes an AU request.
* @since 10.0.0
*/
public void service(org.zkoss.zk.au.AuRequest request, boolean everError) {
final String cmd = request.getCommand();
if (cmd.equals(Events.ON_CHECK)) {
// for compatibility (before 10.0.0), the target of check event in radio group should be the checked radio
CheckEvent evt = new CheckEvent(request.getCommand(), getSelectedItem(), (Boolean) request.getData().get(""));
Events.postEvent(this, evt);
} else
super.service(request, everError);
}
public void onPageAttached(Page newpage, Page oldpage) {
super.onPageAttached(newpage, oldpage);
if (_model != null) {
postOnInitRender(null);
if (_dataListener != null) {
_model.removeListDataListener(_dataListener);
_model.addListDataListener(_dataListener);
}
}
}
public void onPageDetached(Page page) {
super.onPageDetached(page);
if (_model != null && _dataListener != null)
_model.removeListDataListener(_dataListener);
}
@Override
public void onChildAdded(Component child) {
super.onChildAdded(child);
if (_disabled) {
if (child instanceof Radio) {
((Radio) child).setDisabled(true);
} else {
// ZK-4810: find all nested radio
List<Radio> items = new ArrayList<Radio>();
getItems0(child, items);
for (Radio radio : items) {
radio.setDisabled(true);
}
}
}
}
}