Java Carousel Part 1: Layout and Animation
29/12/06 11:34 |
Java
In the first installment we looked at what we needed
to do to recreate the FrontRow-esque carosel as a
standard Swing component. The first step is the
LayoutManager, followed by the animation to rotate
the carosel.
So without any more waffle, let's start with our Layout Manager
The first thing we need is a class to test our layout manager and a couple of components to it, here's that code (you'll need it to test!)
public class CaroselLayoutManager extends JFrame{
/** Creates a new instance of CaroselLayoutManager */
public CaroselLayoutManager() {
super("Carosel Layout Manager");
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setSize(600,400);
getContentPane().setLayout(new CaroselLayout(getContentPane()));
getContentPane().add("Label Example",new JLabel("Example with text"));
getContentPane().add("Button Example", new JButton("Oh, and a button too!"));
getContentPane().add("Text Field", new JTextField("Edit me!"));
getContentPane().add("Image example",new JLabel(new ImageIcon(CaroselLayoutManager.class.getResource("/com/blogofbug/examples/carosel/itunes.png"))));
}
/**
* @param args the command line arguments
*/
public static void main(String args[]) {
java.awt.EventQueue.invokeLater(new Runnable() {
public void run() {
new CaroselLayoutManager().setVisible(true);
}
});
}
}
Inheriting from a JFrame, the constructor sets the layout manager to our CaroselLayout (coming soon!) and then adds a couple of standard components to it, that should be enough to test (thanks again to PixelNet Design for permission to use the images). Nothing too exciting here, so what does the layout manager class look like?
public class CaroselLayout implements LayoutManager,ActionListener{
protected int numberOfItems = 0;
protected LinkedList components = new LinkedList();
protected Hashtable additionalData = new Hashtable();
protected double rotationalOffset = 0.0;
protected double targetOffset = 0.0;
private Timer animationTimer = null;
private Container container = null;
public CaroselLayout(Container forContainer){
animationTimer = new Timer(0,this);
container = forContainer;
}
/**
* Name is ignored
*
*/
public void addLayoutComponent(String name, Component comp) {
components.addLast(comp);
recalculateCarosel();
}
/**
* Remove the component
*
*/
public void removeLayoutComponent(Component comp) {
components.remove(comp);
recalculateCarosel();
}
/**
* Gets the additional data stored by the layout manager for a given component
*
* @param comp The component you wish retreive the data for
* @return A position, which is added if it does not already exist. Never null unless
* you run out of memory!
*/
protected CaroselPosition getPosition(Component comp){
CaroselPosition cpos = (CaroselPosition) additionalData.get(comp);
if (cpos==null){
cpos = new CaroselPosition(comp);
additionalData.put(comp,cpos);
}
return cpos;
}
protected int recalculateVisibleItems(){
int visibleItems=0;
try{
for (Component comp : components){
if (comp.isVisible()){
visibleItems++;
}
}
} catch (ConcurrentModificationException ex){
return recalculateVisibleItems();
}
return visibleItems;
}
protected void recalculateCarosel(){
//Need to count visible, not just how many in the list
//Again dealing with out-of-EDT modification
numberOfItems = recalculateVisibleItems();
//Trap and re-calc on concurrent modification (might as well be up-to-date)
try{
boolean animate=false;
double itemCount = 0;
for (Component comp : components){
CaroselPosition position = getPosition(comp);
if (comp.isVisible()){
double localAngle = itemCount * (Math.PI * 2.0 / (double) numberOfItems);
position.setAngle(localAngle);
}
if (position.isAnimating()){
animate=true;
}
itemCount+=1.0;
}
//If we do need to animate, get it started
if (animate){
animationTimer.start();
}
} catch (ConcurrentModificationException ex){
recalculateCarosel();
return;
}
}
/**
* Cheats and bases it's size on the prefered sizes of each component
*
*/
public Dimension minimumLayoutSize(Container parent) {
return preferredLayoutSize(parent);
}
/**
* Determine the widest and tallest dimensions, then return the height as 1.5 * the highest, and 3 * the widest
*
* @param parent The container for the layout
*/
public Dimension preferredLayoutSize(Container parent) {
Dimension dim = new Dimension(0, 0);
// get widest preferred width for left && right
// get highest preferred height for left && right
// add preferred width of middle
int widestWidth = 0;
int highestHeight = 0;
Iterator i = components.iterator();
while (i.hasNext()){
Component comp = (Component) i.next();
if (comp.isVisible()){
widestWidth = Math.max(widestWidth, comp.getPreferredSize().width);
highestHeight = Math.max(highestHeight, comp.getPreferredSize().height);
}
}
dim.width = widestWidth * 3;
dim.height = highestHeight * 2;
Insets insets = parent.getInsets();
dim.width += insets.left + insets.right;
dim.height += insets.top + insets.bottom;
return dim;
}
/**
* Lays out all of the components on the carosel. Using the preferred width and height to base
* scaling on
*/
public void layoutContainer(Container target) {
//Local copy of components to avoid concurrent modification
//which could happen if someone adds something to the layout outside
//of the EDT. This is faster than do any synchronization or brute force
//exception catching
LinkedList components = (LinkedList) this.components.clone();
int numberOfItems = this.numberOfItems;
recalculateCarosel();
// these variables hold the position where we can draw components
// taking into account insets
Insets insets = target.getInsets();
int width = target.getSize().width - (insets.left + insets.right);
int height = target.getSize().height - (insets.top + insets.bottom);
int widestWidth = 0;
int highestHeight = 0;
Iterator i = components.iterator();
while (i.hasNext()){
Component comp = (Component) i.next();
if (comp.isVisible()){
widestWidth = Math.max(widestWidth, comp.getPreferredSize().width);
}
}
width -= widestWidth;
int radiusX = width /2;
int radiusY = radiusX / 3;
int centerX = (insets.left+widestWidth/2) + width/2;
int centerY = insets.top + height/2;
//Go through each visible component and set the scale and z-order, and eventually the bounds
//Need to protected against other things adding components at the same time
i = components.iterator();
int p = 0;
CaroselPosition z_order[] = new CaroselPosition[numberOfItems];
while (i.hasNext()){
Component comp = (Component) i.next();
CaroselPosition position = getPosition(comp);
double finalAngle = position.getAngle()+this.rotationalOffset;
double x = (Math.sin(finalAngle) * (double) radiusX)+(double) centerX;
double y = (Math.cos(finalAngle) * (double) radiusY)+(double) centerY;
double s = (y / (double) centerY);
double boundsWidth = (double) comp.getPreferredSize().width * s;
double boundsHeight = (double) comp.getPreferredSize().height * s;
comp.setBounds((int)x - ((int)boundsWidth/2),(int) y - ((int)boundsHeight /2),(int) boundsWidth, (int) boundsHeight);
position.setZ(s);
z_order[p++] = position;
}
//Now sort out the z, we may need to cache the dimensions, do the z and then reset the bounds, see what happens on redraw first
//bubble sort is actually very fast for a small number of items, and this layout shouldn't be used for loads.
boolean swaps = true;
int limit = numberOfItems-1;
while (swaps){
swaps = false;
for (int j=0;j
if (z_order[j].getZ()
CaroselPosition temp = z_order[j+1];
z_order[j+1]=z_order[j];
z_order[j]=temp;
swaps=true;
}
}
limit--;
//We must be done if we hit the bottom
if (limit==0){
swaps=false;
}
}
//Re-order everything (yet as little as possible
for (int j=0;j
if (target.getComponentZOrder(z_order[j].getComponent())!=j){
target.setComponentZOrder(z_order[j].getComponent(),j);
}
}
}
/**
* Returns the current rotational angle
*
* @return The current rotated angle in radians
*/
public double getAngle() {
return this.rotationalOffset;
}
/**
* Sets the current rotational angle. Will not cause an animation to start
*
* @param d The desired angle in radians
*/
public void setAngle(double d) {
this.rotationalOffset = d;
}
/**
* Determines if an animation is currently playing
*
* @return true if it is animating, false if it isn't
*/
protected boolean isAnimating(){
if (!animationTimer.isRunning()){
return false;
}
try{
for (Component comp : components) {
CaroselPosition cpos = getPosition(comp);
if (cpos.isAnimating()){
return true;
}
}
} catch (ConcurrentModificationException ex){
return isAnimating();
}
if (Math.abs(rotationalOffset - targetOffset) < 0.001){
return false;
} else {
return true;
}
}
/**
* Manages timer actions, terminating the timer if any event is fully achieved
*
* @param actionEvent the action event, although this will always be the timer
*/
public void actionPerformed(ActionEvent actionEvent) {
if (animationTimer==null){
return;
}
if (!animationTimer.isRunning()){
return;
}
if (!isAnimating()){
animationTimer.stop();
return;
}
//Update any animating icons, could be subject to modification
//outside the EDT
try {
for (Component comp : components) {
CaroselPosition cpos = getPosition(comp);
if (cpos.isAnimating()){
cpos.updateAngle();
}
}
} catch (ConcurrentModificationException cMe){
actionPerformed(actionEvent);
}
rotationalOffset += (targetOffset - rotationalOffset) / 6.0;
if (container!=null){
this.layoutContainer(container);
if (container instanceof Component){
((Component) container).repaint();
}
}
}
/**
* Moves everything to their "target" positions, without animating anything
*
*/
public void finalizeLayoutImmediately(){
for (Component comp : components){
CaroselPosition cpos = getPosition(comp);
cpos.angle=cpos.targetAngle;
}
rotationalOffset = targetOffset;
recalculateCarosel();
container.validate();
}
/**
* Sets a target angle to rotate to, always choses a direction that is less than
* or equal to 180 degrees
*
* @parm target The target angle in radians
*/
private void setTarget(double target){
//We should never have to rotate more than PI radians
while (Math.abs(target-rotationalOffset) > Math.PI){
if (target
target += Math.PI * 2;
} else {
target -= Math.PI * 2;
}
}
targetOffset = target;
if (!animationTimer.isRunning()){
animationTimer.setCoalesce(true);
animationTimer.setRepeats(true);
animationTimer.setDelay(20);
animationTimer.start();
}
}
/**
* Moves the specified component to the front
*
* @param component The component move to the front
*/
public void setFrontMostComponent(Component component){
setTarget(-getPosition(component).getTargetAngle());
}
protected class CaroselPosition{
protected double angle;
protected double scale;
protected double z;
protected Component component;
protected boolean firstSet = false;
protected double targetAngle = 0.0;
public CaroselPosition(Component component){
angle = 0.0;
scale = 0.0;
z = 0.0;
this.component = component;
}
public Component getComponent(){
return component;
}
public double getZ(){
return z;
}
public void setZ(double z){
this.z = z;
}
public double getTargetAngle(){
return targetAngle;
}
public double getAngle(){
return angle;
}
public double getScale(){
return scale;
}
public boolean isAnimating(){
if ((Math.abs(angle - targetAngle) < 0.001)){
return false;
}
return true;
}
public void moveToTarget(){
angle=targetAngle;
}
public void updateAngle(){
if ((Math.abs(angle - targetAngle) < 0.001)){
angle = targetAngle;
} else {
angle += Math.min((targetAngle - angle) / 6.0,0.10);
}
}
public void setAngle(double angle){
if (firstSet){
this.angle = angle;
this.targetAngle = angle;
firstSet = false;
} else {
this.targetAngle = angle;
}
}
public void setScale(double scale){
this.scale = scale;
}
}
}
Still with me? OK, let's break it down and go through it a bit at a time.
public CaroselLayout(Container forContainer){
animationTimer = new Timer(0,this);
container = forContainer;
}
The constructor takes a parameter which is the container it is responsible for laying out. This is needed because our layout manager is going to be responsible for looking after it's own animated transitions, and needs to tell the container to redraw itself when it does animate. So we keep a record of that. Next we will need a swing Timer to actually update the animations (more of these later), right now we just create it.
Our layout manager implements the LayoutManager interface (OK I'm lazy, there are less methods to implement than LayoutManager2), so there are some methods we have to implement....
/**
* Name is ignored
*
*/
public void addLayoutComponent(String name, Component comp) {
components.addLast(comp);
recalculateCarosel();
}
/**
* Remove the component
*
*/
public void removeLayoutComponent(Component comp) {
components.remove(comp);
recalculateCarosel();
}
These first two add and remove components from the layout. We are keeping our own record of the components, so we add or remove from our list, and then call recalculateCarosel() which determines some of the basic parameters of our layout. Let's take a look at that now:
protected void recalculateCarosel(){
//Need to count visible, not just how many in the list
//Again dealing with out-of-EDT modification
numberOfItems = recalculateVisibleItems();
//Trap and re-calc on concurrent modification (might as well be up-to-date)
try{
boolean animate=false;
double itemCount = 0;
for (Component comp : components){
CaroselPosition position = getPosition(comp);
if (comp.isVisible()){
double localAngle = itemCount * (Math.PI * 2.0 / (double) numberOfItems);
position.setAngle(localAngle);
}
if (position.isAnimating()){
animate=true;
}
itemCount+=1.0;
}
//If we do need to animate, get it started
if (animate){
animationTimer.start();
}
} catch (ConcurrentModificationException ex){
recalculateCarosel();
return;
}
}
The first thing it does is count the number of visible items (just iterates through our component list, counting each one that is visible). We then go through the components list, spacing out each component around a circle. The quicker ones amongst you will have noticed the getPosition method. Basically we want to store extra information about each the components in our layout (their position around the carosel circle, for animations where they need to be, and where they are now, their current size, and their z-order (how close to the front they are)). The getPosition() method retrieves this information for our component from that list.
It's also worth noting that when we set the angle it should be at, the CaroselPosition class has an idea of a target angle (where it needs to be) and a current angle (where it is now).
Is anyone of the components is going to need animating, then the animation timer is started. We'll see what that does later.
There are some other methods we need to implement for a layout manager, specifically determining the maximum, minimum and prefered layout size. I'm cheating, and only determining the prefered size, and saying everything else is equal to that.
/**
* Cheats and bases it's size on the prefered sizes of each component
*
*/
public Dimension minimumLayoutSize(Container parent) {
return preferredLayoutSize(parent);
}
/**
* Determine the widest and tallest dimensions, then return the height as 1.5 * the highest, and 3 * the widest
*
* @param parent The container for the layout
*/
public Dimension preferredLayoutSize(Container parent) {
Dimension dim = new Dimension(0, 0);
// get widest preferred width for left && right
// get highest preferred height for left && right
// add preferred width of middle
int widestWidth = 0;
int highestHeight = 0;
Iterator i = components.iterator();
while (i.hasNext()){
Component comp = (Component) i.next();
if (comp.isVisible()){
widestWidth = Math.max(widestWidth, comp.getPreferredSize().width);
highestHeight = Math.max(highestHeight, comp.getPreferredSize().height);
}
}
dim.width = widestWidth * 3;
dim.height = highestHeight * 2;
Insets insets = parent.getInsets();
dim.width += insets.left + insets.right;
dim.height += insets.top + insets.bottom;
return dim;
}
The more interesting method is the preferredLayoutSize(), basically we look at the preferred size of each of the components to be displayed, and find the tallest one, and the widest one (well their size anyway). We don't want a perfect circle for our carosel, and we want room for at least two of our tallest/widest and highest to be next to each other... that's our base, but as already stated we also want our circle to be an elipse, so we multiple our width by 3 instead of 2.
Finally we add the insets of the container on and return that.
Just one more required method, and it's a big one! layoutContainer needs to set the position and size of every component in the layout, and as such it's the real meat of our method.
Let's break it up and look at it in stages, first let's look at the basic setup that is performed
/**
* Lays out all of the components on the carosel. Using the preferred width and height to base
* scaling on
*/
public void layoutContainer(Container target) {
//Local copy of components to avoid concurrent modification
//which could happen if someone adds something to the layout outside
//of the EDT. This is faster than do any synchronization or brute force
//exception catching
LinkedList components = (LinkedList) this.components.clone();
int numberOfItems = this.numberOfItems;
recalculateCarosel();
// these variables hold the position where we can draw components
// taking into account insets
Insets insets = target.getInsets();
int width = target.getSize().width - (insets.left + insets.right);
int height = target.getSize().height - (insets.top + insets.bottom);
We make a local copy of the components list so that if something gets added or removed in another thread while we are laying out we don't get a concurrent modification exception. Then we determine how big the container is (minus the indents). So, now we know what we have to work with, we need to determine a few parameters about what we want to add.
int widestWidth = 0;
Iterator i = components.iterator();
while (i.hasNext()){
Component comp = (Component) i.next();
if (comp.isVisible()){
widestWidth = Math.max(widestWidth, comp.getPreferredSize().width);
}
}
width -= widestWidth;
We know that the major axis is the horizontal one, so we only consider the width. After determining the widest component, we take that off the width.
int radiusX = width /2;
int radiusY = radiusX / 3;
What is the radius of our elipse (on both axis). Again, the Y is scaled more than the X axis.
int centerX = (insets.left+widestWidth/2) + width/2;
int centerY = insets.top + height/2;
The center of our container around which all of components are laid out.
//Go through each visible component and set the scale and z-order, and eventually the bounds
//Need to protected against other things adding components at the same time
i = components.iterator();
int p = 0;
CaroselPosition z_order[] = new CaroselPosition[numberOfItems];
while (i.hasNext()){
Component comp = (Component) i.next();
CaroselPosition position = getPosition(comp);
double finalAngle = position.getAngle()+this.rotationalOffset;
double x = (Math.sin(finalAngle) * (double) radiusX)+(double) centerX;
double y = (Math.cos(finalAngle) * (double) radiusY)+(double) centerY;
double s = (y / (double) centerY);
double boundsWidth = (double) comp.getPreferredSize().width * s;
double boundsHeight = (double) comp.getPreferredSize().height * s;
comp.setBounds((int)x - ((int)boundsWidth/2),(int) y - ((int)boundsHeight /2),(int) boundsWidth, (int) boundsHeight);
position.setZ(s);
z_order[p++] = position;
}
There you go, that's the math part! Not too hard was it? Basically we use a bit of trig together with the angle of the component to calculate the width and height of the component. The final angle is determined based on the current angle of the component, the overall rotation of the whole carosel (rotationalOffset, used to move components to the front or back). Once the x,y position are determined we need to know how to scale the component (they should get bigger at the front, and smaller at the back). We do this the really easy way which is to take the y co-ordinate (further down the screen it is, the bigger it should be drawn). We then scale the component's preferred size by the factor.
We also use the scale to determine the z position of the compnent (we don't want swing drawing a back-most component over the front most). We are going to sort an order these in the next step, so we record the target z before looping back to the next component.
//Now sort out the z, we may need to cache the dimensions, do the z and then reset the bounds, see what happens on redraw first
//bubble sort is actually very fast for a small number of items, and this layout shouldn't be used for loads.
boolean swaps = true;
int limit = numberOfItems-1;
while (swaps){
swaps = false;
for (int j=0;j
if (z_order[j].getZ()
CaroselPosition temp = z_order[j+1];
z_order[j+1]=z_order[j];
z_order[j]=temp;
swaps=true;
}
}
limit--;
//We must be done if we hit the bottom
if (limit==0){
swaps=false;
}
}
//Re-order everything (yet as little as possible
for (int j=0;j
if (target.getComponentZOrder(z_order[j].getComponent())!=j){
target.setComponentZOrder(z_order[j].getComponent(),j);
}
}
}
A simple bubble sort is used to order the components biggest to smallest (frontmost to backmost), and then commiting it to the swing z-order. And that's it, our carosel is complete. Without worrying about the animation, there's nothing more to know. You end up with this...
It's a little hard to see, but our four components are laid out in a circle...
So there's still quite a lot of code we haven't looked at, let's move on and start looking at how the animation works. A useful way of seeing what we animate, is to look at the isAnimating method. It determines if the animation timer should be stopped, by considering all of the things that may cause animation.
/**
* Determines if an animation is currently playing
*
* @return true if it is animating, false if it isn't
*/
protected boolean isAnimating(){
if (!animationTimer.isRunning()){
return false;
}
try{
for (Component comp : components) {
CaroselPosition cpos = getPosition(comp);
if (cpos.isAnimating()){
return true;
}
}
} catch (ConcurrentModificationException ex){
return isAnimating();
}
if (Math.abs(rotationalOffset - targetOffset) < 0.001){
return false;
} else {
return true;
}
}
At the start it looks at each component to see if they are in their final position (our components animate into their final position), and also looks at the overall rotationalOffset to see if it needs to carry on spinning a component to the front. So what do we do to animate? It's really very simple, the actionPerformed method is called when the timer fires, so let's see what it does.
/**
* Manages timer actions, terminating the timer if any event is fully achieved
*
* @param actionEvent the action event, although this will always be the timer
*/
public void actionPerformed(ActionEvent actionEvent) {
if (animationTimer==null){
return;
}
if (!animationTimer.isRunning()){
return;
}
if (!isAnimating()){
animationTimer.stop();
return;
}
//Update any animating icons, could be subject to modification
//outside the EDT
try {
for (Component comp : components) {
CaroselPosition cpos = getPosition(comp);
if (cpos.isAnimating()){
cpos.updateAngle();
}
}
} catch (ConcurrentModificationException cMe){
actionPerformed(actionEvent);
}
rotationalOffset += (targetOffset - rotationalOffset) / 6.0;
if (container!=null){
this.layoutContainer(container);
if (container instanceof Component){
((Component) container).repaint();
}
}
}
It looks at each component, see's if they are animating their position and updates their position if they are. In addition it also looks at the target angle for the overall carosel and closes the gap a little if it isn't there yet. If there is nothing left to animate, it stops the timer.
That's all very well, but how do we make our carosel spin something to the front? Well it's not up to our layout manager to decide if it needs to, but it should provide some methods to objects that use it so they can specifying a component to move to the front. The setFrontmostComponent method achieves this, itself using a private setTarget() method.
/**
* Sets a target angle to rotate to, always choses a direction that is less than
* or equal to 180 degrees
*
* @parm target The target angle in radians
*/
private void setTarget(double target){
//We should never have to rotate more than PI radians
while (Math.abs(target-rotationalOffset) > Math.PI){
if (target
target += Math.PI * 2;
} else {
target -= Math.PI * 2;
}
}
targetOffset = target;
if (!animationTimer.isRunning()){
animationTimer.setCoalesce(true);
animationTimer.setRepeats(true);
animationTimer.setDelay(20);
animationTimer.start();
}
}
/**
* Moves the specified component to the front
*
* @param component The component move to the front
*/
public void setFrontMostComponent(Component component){
setTarget(-getPosition(component).getTargetAngle());
}
Set front most component extracts the angle of the specified component from our list of additional component position data, and calls set target with that angle. setTarget takes the angle and determines the "shortest" route to the target angle (clockwise or anti-clockwise). It sets up the target offset, and starts the animation timer if it needs to. That's it. Pretty easy huh?
In the next blog we'll look at deriving a new component from JPanel which performs some of the other useful tasks... and oh-yes.... we'll make it look sexy too!
Note: The iTunes CD Images used in this example are reproduced here by kind permission of Brian Zeitler from PixelNet Design and please note that all copyright remains with Brian and PixelNet Design.
So without any more waffle, let's start with our Layout Manager
The first thing we need is a class to test our layout manager and a couple of components to it, here's that code (you'll need it to test!)
public class CaroselLayoutManager extends JFrame{
/** Creates a new instance of CaroselLayoutManager */
public CaroselLayoutManager() {
super("Carosel Layout Manager");
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setSize(600,400);
getContentPane().setLayout(new CaroselLayout(getContentPane()));
getContentPane().add("Label Example",new JLabel("Example with text"));
getContentPane().add("Button Example", new JButton("Oh, and a button too!"));
getContentPane().add("Text Field", new JTextField("Edit me!"));
getContentPane().add("Image example",new JLabel(new ImageIcon(CaroselLayoutManager.class.getResource("/com/blogofbug/examples/carosel/itunes.png"))));
}
/**
* @param args the command line arguments
*/
public static void main(String args[]) {
java.awt.EventQueue.invokeLater(new Runnable() {
public void run() {
new CaroselLayoutManager().setVisible(true);
}
});
}
}
Inheriting from a JFrame, the constructor sets the layout manager to our CaroselLayout (coming soon!) and then adds a couple of standard components to it, that should be enough to test (thanks again to PixelNet Design for permission to use the images). Nothing too exciting here, so what does the layout manager class look like?
public class CaroselLayout implements LayoutManager,ActionListener{
protected int numberOfItems = 0;
protected LinkedList components = new LinkedList();
protected Hashtable additionalData = new Hashtable();
protected double rotationalOffset = 0.0;
protected double targetOffset = 0.0;
private Timer animationTimer = null;
private Container container = null;
public CaroselLayout(Container forContainer){
animationTimer = new Timer(0,this);
container = forContainer;
}
/**
* Name is ignored
*
*/
public void addLayoutComponent(String name, Component comp) {
components.addLast(comp);
recalculateCarosel();
}
/**
* Remove the component
*
*/
public void removeLayoutComponent(Component comp) {
components.remove(comp);
recalculateCarosel();
}
/**
* Gets the additional data stored by the layout manager for a given component
*
* @param comp The component you wish retreive the data for
* @return A position, which is added if it does not already exist. Never null unless
* you run out of memory!
*/
protected CaroselPosition getPosition(Component comp){
CaroselPosition cpos = (CaroselPosition) additionalData.get(comp);
if (cpos==null){
cpos = new CaroselPosition(comp);
additionalData.put(comp,cpos);
}
return cpos;
}
protected int recalculateVisibleItems(){
int visibleItems=0;
try{
for (Component comp : components){
if (comp.isVisible()){
visibleItems++;
}
}
} catch (ConcurrentModificationException ex){
return recalculateVisibleItems();
}
return visibleItems;
}
protected void recalculateCarosel(){
//Need to count visible, not just how many in the list
//Again dealing with out-of-EDT modification
numberOfItems = recalculateVisibleItems();
//Trap and re-calc on concurrent modification (might as well be up-to-date)
try{
boolean animate=false;
double itemCount = 0;
for (Component comp : components){
CaroselPosition position = getPosition(comp);
if (comp.isVisible()){
double localAngle = itemCount * (Math.PI * 2.0 / (double) numberOfItems);
position.setAngle(localAngle);
}
if (position.isAnimating()){
animate=true;
}
itemCount+=1.0;
}
//If we do need to animate, get it started
if (animate){
animationTimer.start();
}
} catch (ConcurrentModificationException ex){
recalculateCarosel();
return;
}
}
/**
* Cheats and bases it's size on the prefered sizes of each component
*
*/
public Dimension minimumLayoutSize(Container parent) {
return preferredLayoutSize(parent);
}
/**
* Determine the widest and tallest dimensions, then return the height as 1.5 * the highest, and 3 * the widest
*
* @param parent The container for the layout
*/
public Dimension preferredLayoutSize(Container parent) {
Dimension dim = new Dimension(0, 0);
// get widest preferred width for left && right
// get highest preferred height for left && right
// add preferred width of middle
int widestWidth = 0;
int highestHeight = 0;
Iterator i = components.iterator();
while (i.hasNext()){
Component comp = (Component) i.next();
if (comp.isVisible()){
widestWidth = Math.max(widestWidth, comp.getPreferredSize().width);
highestHeight = Math.max(highestHeight, comp.getPreferredSize().height);
}
}
dim.width = widestWidth * 3;
dim.height = highestHeight * 2;
Insets insets = parent.getInsets();
dim.width += insets.left + insets.right;
dim.height += insets.top + insets.bottom;
return dim;
}
/**
* Lays out all of the components on the carosel. Using the preferred width and height to base
* scaling on
*/
public void layoutContainer(Container target) {
//Local copy of components to avoid concurrent modification
//which could happen if someone adds something to the layout outside
//of the EDT. This is faster than do any synchronization or brute force
//exception catching
LinkedList components = (LinkedList) this.components.clone();
int numberOfItems = this.numberOfItems;
recalculateCarosel();
// these variables hold the position where we can draw components
// taking into account insets
Insets insets = target.getInsets();
int width = target.getSize().width - (insets.left + insets.right);
int height = target.getSize().height - (insets.top + insets.bottom);
int widestWidth = 0;
int highestHeight = 0;
Iterator i = components.iterator();
while (i.hasNext()){
Component comp = (Component) i.next();
if (comp.isVisible()){
widestWidth = Math.max(widestWidth, comp.getPreferredSize().width);
}
}
width -= widestWidth;
int radiusX = width /2;
int radiusY = radiusX / 3;
int centerX = (insets.left+widestWidth/2) + width/2;
int centerY = insets.top + height/2;
//Go through each visible component and set the scale and z-order, and eventually the bounds
//Need to protected against other things adding components at the same time
i = components.iterator();
int p = 0;
CaroselPosition z_order[] = new CaroselPosition[numberOfItems];
while (i.hasNext()){
Component comp = (Component) i.next();
CaroselPosition position = getPosition(comp);
double finalAngle = position.getAngle()+this.rotationalOffset;
double x = (Math.sin(finalAngle) * (double) radiusX)+(double) centerX;
double y = (Math.cos(finalAngle) * (double) radiusY)+(double) centerY;
double s = (y / (double) centerY);
double boundsWidth = (double) comp.getPreferredSize().width * s;
double boundsHeight = (double) comp.getPreferredSize().height * s;
comp.setBounds((int)x - ((int)boundsWidth/2),(int) y - ((int)boundsHeight /2),(int) boundsWidth, (int) boundsHeight);
position.setZ(s);
z_order[p++] = position;
}
//Now sort out the z, we may need to cache the dimensions, do the z and then reset the bounds, see what happens on redraw first
//bubble sort is actually very fast for a small number of items, and this layout shouldn't be used for loads.
boolean swaps = true;
int limit = numberOfItems-1;
while (swaps){
swaps = false;
for (int j=0;j
if (z_order[j].getZ()
CaroselPosition temp = z_order[j+1];
z_order[j+1]=z_order[j];
z_order[j]=temp;
swaps=true;
}
}
limit--;
//We must be done if we hit the bottom
if (limit==0){
swaps=false;
}
}
//Re-order everything (yet as little as possible
for (int j=0;j
if (target.getComponentZOrder(z_order[j].getComponent())!=j){
target.setComponentZOrder(z_order[j].getComponent(),j);
}
}
}
/**
* Returns the current rotational angle
*
* @return The current rotated angle in radians
*/
public double getAngle() {
return this.rotationalOffset;
}
/**
* Sets the current rotational angle. Will not cause an animation to start
*
* @param d The desired angle in radians
*/
public void setAngle(double d) {
this.rotationalOffset = d;
}
/**
* Determines if an animation is currently playing
*
* @return true if it is animating, false if it isn't
*/
protected boolean isAnimating(){
if (!animationTimer.isRunning()){
return false;
}
try{
for (Component comp : components) {
CaroselPosition cpos = getPosition(comp);
if (cpos.isAnimating()){
return true;
}
}
} catch (ConcurrentModificationException ex){
return isAnimating();
}
if (Math.abs(rotationalOffset - targetOffset) < 0.001){
return false;
} else {
return true;
}
}
/**
* Manages timer actions, terminating the timer if any event is fully achieved
*
* @param actionEvent the action event, although this will always be the timer
*/
public void actionPerformed(ActionEvent actionEvent) {
if (animationTimer==null){
return;
}
if (!animationTimer.isRunning()){
return;
}
if (!isAnimating()){
animationTimer.stop();
return;
}
//Update any animating icons, could be subject to modification
//outside the EDT
try {
for (Component comp : components) {
CaroselPosition cpos = getPosition(comp);
if (cpos.isAnimating()){
cpos.updateAngle();
}
}
} catch (ConcurrentModificationException cMe){
actionPerformed(actionEvent);
}
rotationalOffset += (targetOffset - rotationalOffset) / 6.0;
if (container!=null){
this.layoutContainer(container);
if (container instanceof Component){
((Component) container).repaint();
}
}
}
/**
* Moves everything to their "target" positions, without animating anything
*
*/
public void finalizeLayoutImmediately(){
for (Component comp : components){
CaroselPosition cpos = getPosition(comp);
cpos.angle=cpos.targetAngle;
}
rotationalOffset = targetOffset;
recalculateCarosel();
container.validate();
}
/**
* Sets a target angle to rotate to, always choses a direction that is less than
* or equal to 180 degrees
*
* @parm target The target angle in radians
*/
private void setTarget(double target){
//We should never have to rotate more than PI radians
while (Math.abs(target-rotationalOffset) > Math.PI){
if (target
target += Math.PI * 2;
} else {
target -= Math.PI * 2;
}
}
targetOffset = target;
if (!animationTimer.isRunning()){
animationTimer.setCoalesce(true);
animationTimer.setRepeats(true);
animationTimer.setDelay(20);
animationTimer.start();
}
}
/**
* Moves the specified component to the front
*
* @param component The component move to the front
*/
public void setFrontMostComponent(Component component){
setTarget(-getPosition(component).getTargetAngle());
}
protected class CaroselPosition{
protected double angle;
protected double scale;
protected double z;
protected Component component;
protected boolean firstSet = false;
protected double targetAngle = 0.0;
public CaroselPosition(Component component){
angle = 0.0;
scale = 0.0;
z = 0.0;
this.component = component;
}
public Component getComponent(){
return component;
}
public double getZ(){
return z;
}
public void setZ(double z){
this.z = z;
}
public double getTargetAngle(){
return targetAngle;
}
public double getAngle(){
return angle;
}
public double getScale(){
return scale;
}
public boolean isAnimating(){
if ((Math.abs(angle - targetAngle) < 0.001)){
return false;
}
return true;
}
public void moveToTarget(){
angle=targetAngle;
}
public void updateAngle(){
if ((Math.abs(angle - targetAngle) < 0.001)){
angle = targetAngle;
} else {
angle += Math.min((targetAngle - angle) / 6.0,0.10);
}
}
public void setAngle(double angle){
if (firstSet){
this.angle = angle;
this.targetAngle = angle;
firstSet = false;
} else {
this.targetAngle = angle;
}
}
public void setScale(double scale){
this.scale = scale;
}
}
}
Still with me? OK, let's break it down and go through it a bit at a time.
public CaroselLayout(Container forContainer){
animationTimer = new Timer(0,this);
container = forContainer;
}
The constructor takes a parameter which is the container it is responsible for laying out. This is needed because our layout manager is going to be responsible for looking after it's own animated transitions, and needs to tell the container to redraw itself when it does animate. So we keep a record of that. Next we will need a swing Timer to actually update the animations (more of these later), right now we just create it.
Our layout manager implements the LayoutManager interface (OK I'm lazy, there are less methods to implement than LayoutManager2), so there are some methods we have to implement....
/**
* Name is ignored
*
*/
public void addLayoutComponent(String name, Component comp) {
components.addLast(comp);
recalculateCarosel();
}
/**
* Remove the component
*
*/
public void removeLayoutComponent(Component comp) {
components.remove(comp);
recalculateCarosel();
}
These first two add and remove components from the layout. We are keeping our own record of the components, so we add or remove from our list, and then call recalculateCarosel() which determines some of the basic parameters of our layout. Let's take a look at that now:
protected void recalculateCarosel(){
//Need to count visible, not just how many in the list
//Again dealing with out-of-EDT modification
numberOfItems = recalculateVisibleItems();
//Trap and re-calc on concurrent modification (might as well be up-to-date)
try{
boolean animate=false;
double itemCount = 0;
for (Component comp : components){
CaroselPosition position = getPosition(comp);
if (comp.isVisible()){
double localAngle = itemCount * (Math.PI * 2.0 / (double) numberOfItems);
position.setAngle(localAngle);
}
if (position.isAnimating()){
animate=true;
}
itemCount+=1.0;
}
//If we do need to animate, get it started
if (animate){
animationTimer.start();
}
} catch (ConcurrentModificationException ex){
recalculateCarosel();
return;
}
}
The first thing it does is count the number of visible items (just iterates through our component list, counting each one that is visible). We then go through the components list, spacing out each component around a circle. The quicker ones amongst you will have noticed the getPosition method. Basically we want to store extra information about each the components in our layout (their position around the carosel circle, for animations where they need to be, and where they are now, their current size, and their z-order (how close to the front they are)). The getPosition() method retrieves this information for our component from that list.
It's also worth noting that when we set the angle it should be at, the CaroselPosition class has an idea of a target angle (where it needs to be) and a current angle (where it is now).
Is anyone of the components is going to need animating, then the animation timer is started. We'll see what that does later.
There are some other methods we need to implement for a layout manager, specifically determining the maximum, minimum and prefered layout size. I'm cheating, and only determining the prefered size, and saying everything else is equal to that.
/**
* Cheats and bases it's size on the prefered sizes of each component
*
*/
public Dimension minimumLayoutSize(Container parent) {
return preferredLayoutSize(parent);
}
/**
* Determine the widest and tallest dimensions, then return the height as 1.5 * the highest, and 3 * the widest
*
* @param parent The container for the layout
*/
public Dimension preferredLayoutSize(Container parent) {
Dimension dim = new Dimension(0, 0);
// get widest preferred width for left && right
// get highest preferred height for left && right
// add preferred width of middle
int widestWidth = 0;
int highestHeight = 0;
Iterator i = components.iterator();
while (i.hasNext()){
Component comp = (Component) i.next();
if (comp.isVisible()){
widestWidth = Math.max(widestWidth, comp.getPreferredSize().width);
highestHeight = Math.max(highestHeight, comp.getPreferredSize().height);
}
}
dim.width = widestWidth * 3;
dim.height = highestHeight * 2;
Insets insets = parent.getInsets();
dim.width += insets.left + insets.right;
dim.height += insets.top + insets.bottom;
return dim;
}
The more interesting method is the preferredLayoutSize(), basically we look at the preferred size of each of the components to be displayed, and find the tallest one, and the widest one (well their size anyway). We don't want a perfect circle for our carosel, and we want room for at least two of our tallest/widest and highest to be next to each other... that's our base, but as already stated we also want our circle to be an elipse, so we multiple our width by 3 instead of 2.
Finally we add the insets of the container on and return that.
Just one more required method, and it's a big one! layoutContainer needs to set the position and size of every component in the layout, and as such it's the real meat of our method.
Let's break it up and look at it in stages, first let's look at the basic setup that is performed
/**
* Lays out all of the components on the carosel. Using the preferred width and height to base
* scaling on
*/
public void layoutContainer(Container target) {
//Local copy of components to avoid concurrent modification
//which could happen if someone adds something to the layout outside
//of the EDT. This is faster than do any synchronization or brute force
//exception catching
LinkedList components = (LinkedList) this.components.clone();
int numberOfItems = this.numberOfItems;
recalculateCarosel();
// these variables hold the position where we can draw components
// taking into account insets
Insets insets = target.getInsets();
int width = target.getSize().width - (insets.left + insets.right);
int height = target.getSize().height - (insets.top + insets.bottom);
We make a local copy of the components list so that if something gets added or removed in another thread while we are laying out we don't get a concurrent modification exception. Then we determine how big the container is (minus the indents). So, now we know what we have to work with, we need to determine a few parameters about what we want to add.
int widestWidth = 0;
Iterator i = components.iterator();
while (i.hasNext()){
Component comp = (Component) i.next();
if (comp.isVisible()){
widestWidth = Math.max(widestWidth, comp.getPreferredSize().width);
}
}
width -= widestWidth;
We know that the major axis is the horizontal one, so we only consider the width. After determining the widest component, we take that off the width.
int radiusX = width /2;
int radiusY = radiusX / 3;
What is the radius of our elipse (on both axis). Again, the Y is scaled more than the X axis.
int centerX = (insets.left+widestWidth/2) + width/2;
int centerY = insets.top + height/2;
The center of our container around which all of components are laid out.
//Go through each visible component and set the scale and z-order, and eventually the bounds
//Need to protected against other things adding components at the same time
i = components.iterator();
int p = 0;
CaroselPosition z_order[] = new CaroselPosition[numberOfItems];
while (i.hasNext()){
Component comp = (Component) i.next();
CaroselPosition position = getPosition(comp);
double finalAngle = position.getAngle()+this.rotationalOffset;
double x = (Math.sin(finalAngle) * (double) radiusX)+(double) centerX;
double y = (Math.cos(finalAngle) * (double) radiusY)+(double) centerY;
double s = (y / (double) centerY);
double boundsWidth = (double) comp.getPreferredSize().width * s;
double boundsHeight = (double) comp.getPreferredSize().height * s;
comp.setBounds((int)x - ((int)boundsWidth/2),(int) y - ((int)boundsHeight /2),(int) boundsWidth, (int) boundsHeight);
position.setZ(s);
z_order[p++] = position;
}
There you go, that's the math part! Not too hard was it? Basically we use a bit of trig together with the angle of the component to calculate the width and height of the component. The final angle is determined based on the current angle of the component, the overall rotation of the whole carosel (rotationalOffset, used to move components to the front or back). Once the x,y position are determined we need to know how to scale the component (they should get bigger at the front, and smaller at the back). We do this the really easy way which is to take the y co-ordinate (further down the screen it is, the bigger it should be drawn). We then scale the component's preferred size by the factor.
We also use the scale to determine the z position of the compnent (we don't want swing drawing a back-most component over the front most). We are going to sort an order these in the next step, so we record the target z before looping back to the next component.
//Now sort out the z, we may need to cache the dimensions, do the z and then reset the bounds, see what happens on redraw first
//bubble sort is actually very fast for a small number of items, and this layout shouldn't be used for loads.
boolean swaps = true;
int limit = numberOfItems-1;
while (swaps){
swaps = false;
for (int j=0;j
if (z_order[j].getZ()
CaroselPosition temp = z_order[j+1];
z_order[j+1]=z_order[j];
z_order[j]=temp;
swaps=true;
}
}
limit--;
//We must be done if we hit the bottom
if (limit==0){
swaps=false;
}
}
//Re-order everything (yet as little as possible
for (int j=0;j
if (target.getComponentZOrder(z_order[j].getComponent())!=j){
target.setComponentZOrder(z_order[j].getComponent(),j);
}
}
}
A simple bubble sort is used to order the components biggest to smallest (frontmost to backmost), and then commiting it to the swing z-order. And that's it, our carosel is complete. Without worrying about the animation, there's nothing more to know. You end up with this...
It's a little hard to see, but our four components are laid out in a circle...
So there's still quite a lot of code we haven't looked at, let's move on and start looking at how the animation works. A useful way of seeing what we animate, is to look at the isAnimating method. It determines if the animation timer should be stopped, by considering all of the things that may cause animation.
/**
* Determines if an animation is currently playing
*
* @return true if it is animating, false if it isn't
*/
protected boolean isAnimating(){
if (!animationTimer.isRunning()){
return false;
}
try{
for (Component comp : components) {
CaroselPosition cpos = getPosition(comp);
if (cpos.isAnimating()){
return true;
}
}
} catch (ConcurrentModificationException ex){
return isAnimating();
}
if (Math.abs(rotationalOffset - targetOffset) < 0.001){
return false;
} else {
return true;
}
}
At the start it looks at each component to see if they are in their final position (our components animate into their final position), and also looks at the overall rotationalOffset to see if it needs to carry on spinning a component to the front. So what do we do to animate? It's really very simple, the actionPerformed method is called when the timer fires, so let's see what it does.
/**
* Manages timer actions, terminating the timer if any event is fully achieved
*
* @param actionEvent the action event, although this will always be the timer
*/
public void actionPerformed(ActionEvent actionEvent) {
if (animationTimer==null){
return;
}
if (!animationTimer.isRunning()){
return;
}
if (!isAnimating()){
animationTimer.stop();
return;
}
//Update any animating icons, could be subject to modification
//outside the EDT
try {
for (Component comp : components) {
CaroselPosition cpos = getPosition(comp);
if (cpos.isAnimating()){
cpos.updateAngle();
}
}
} catch (ConcurrentModificationException cMe){
actionPerformed(actionEvent);
}
rotationalOffset += (targetOffset - rotationalOffset) / 6.0;
if (container!=null){
this.layoutContainer(container);
if (container instanceof Component){
((Component) container).repaint();
}
}
}
It looks at each component, see's if they are animating their position and updates their position if they are. In addition it also looks at the target angle for the overall carosel and closes the gap a little if it isn't there yet. If there is nothing left to animate, it stops the timer.
That's all very well, but how do we make our carosel spin something to the front? Well it's not up to our layout manager to decide if it needs to, but it should provide some methods to objects that use it so they can specifying a component to move to the front. The setFrontmostComponent method achieves this, itself using a private setTarget() method.
/**
* Sets a target angle to rotate to, always choses a direction that is less than
* or equal to 180 degrees
*
* @parm target The target angle in radians
*/
private void setTarget(double target){
//We should never have to rotate more than PI radians
while (Math.abs(target-rotationalOffset) > Math.PI){
if (target
target += Math.PI * 2;
} else {
target -= Math.PI * 2;
}
}
targetOffset = target;
if (!animationTimer.isRunning()){
animationTimer.setCoalesce(true);
animationTimer.setRepeats(true);
animationTimer.setDelay(20);
animationTimer.start();
}
}
/**
* Moves the specified component to the front
*
* @param component The component move to the front
*/
public void setFrontMostComponent(Component component){
setTarget(-getPosition(component).getTargetAngle());
}
Set front most component extracts the angle of the specified component from our list of additional component position data, and calls set target with that angle. setTarget takes the angle and determines the "shortest" route to the target angle (clockwise or anti-clockwise). It sets up the target offset, and starts the animation timer if it needs to. That's it. Pretty easy huh?
In the next blog we'll look at deriving a new component from JPanel which performs some of the other useful tasks... and oh-yes.... we'll make it look sexy too!
Note: The iTunes CD Images used in this example are reproduced here by kind permission of Brian Zeitler from PixelNet Design and please note that all copyright remains with Brian and PixelNet Design.
|