Mac apps don’t have to look like Mac apps
October 30, 2009
A common misconception is that Mac apps must look like Apple’s own apps. A lot of developers misinterpret Mac users’ high visual standards as a call for plain Cocoa apps only, with no visual innovation or interpretation. What Mac users are really saying, is:
Give me an app that looks at least as good as what Apple produces
In fact, Apple is the first to break from using standard Cocoa (think iTunes). Do users let this slide only because it’s Apple? I don’t think so. Here are a number of applications (including iTunes) that don’t look like standard apps, and are well received by the Mac community.
Lightroom 3 (beta)
![]()
Lightroom 3 has a completely non-standard UI and interaction model, and I love it! The UI is dark and stays out of your way. The “links” in the top right of the window (e.g. Library, Develop etc.) let you quickly adjust your workflow to the task at hand. Overall, the UI is polished, snappy and a pleasure to use.
Coda
![]()
Panic’s Coda may look like a Mac app at first, but it was one of the first apps to successfully embrace the one-window paradigm, which was very non-standard for Mac apps when it was released (more recently, Adobe has adopted this concept). Coda also helps you switch workflows by changing the active “tab” (the buttons above the document area). This concept was, and still is, something very unique to Coda. Coda won an Apple Design Award, and is lauded as a truly fantastic Mac app.
Pixelmator
![]()
Pixelmator offers a clean, crisp UI for editing photos. They’ve played off Apple’s Heads Up Display (HUD) concept, but pushed it throughout the app to everything including the document window’s chrome. I think their UI is gorgeous and unobtrusive — it makes me want to use it just so I can look at it.
iTunes
![]()
iTunes, though one of Apple’s own products, is consistently different from the core platform. Even though it’s not consistent with other apps, I’m happy with it because it looks good. I enjoy being able to see what UI changes are in the pipeline, as iTunes has been a proving ground for more general user interface changes across the platform.
I could keep going, but I think the point is clear. User interface innovation is widely accepted by Mac users so long as it is an innovation and not sloppiness.
Josh [Marinacci] on Design
October 26, 2009
![]()
I stumbled across Josh on Design today, a relatively new blog started by Josh Marinacci with the aim of discussing good user interfaces. It looks like Josh’s focus will be more around design-y type issues and less around implementation, which should help to fill a space that more developers need to explore.
There isn’t much content on the blog yet, but I think you’ll find this article on color informative. I also found this bit of Josh’s professional history interesting.
Sea Glass look and feel
October 1, 2009
I’ve teamed up with Kathryn Huxtable on a new look and feel for Java we’re calling “Sea Glass”. The look was inspired by the following image:
![]()
I’ve been working on the artwork, while Kathryn has been doing the hard part of converting the art into an actual Java look and feel. We’ve only just begun the project, so there is much yet to be done, but I’d like to share the following glimpses of the artwork with you:
![]()
![]()
The colors will likely be tweaked as we go, but the images above give you a feel for what we’re going for. I’ll provide a link for the project shortly, which will be open source.
If you have comments or suggestions, be sure to leave them below.
Adobe’s Flex icon and JavaFX confusion
September 17, 2009
As I was reading this IntelliJ blog article, I noticed what I thought to be a new JavaFX icon (the Fx text on a black gradient background).
Once I started reading the article, I realized this wasn’t about JavaFX at all, but support for Adobe’s Flex! I guess the lower case “x” in “Fx” should have been a give-away that it wasn’t JavaFX, but I wasn’t paying close attention. That made me wonder, is there a potential marketing conflict here? Could this possibly have been an intentional choice on Adobe’s part, or did Sun choose a bad suffix for Java?
I don’t know any of the history on either side, so I can’t really speculate too much. At any rate, I think there’s some potential for confusion in Adobe’s favor.
Google Chrome makes good use of window chrome
September 1, 2009
I was playing around with the Mac version of Google Chrome the other day (I found it here) and I stumbled across the “Incognito” mode. When you go incognito, you’re going into private-browsing mode, i.e. you won’t leave traces of where you’ve been.
What I really liked about the user interface for this was the use of different window title bar for the incognito window. In general, modes are not great for users because it’s difficult to convey what mode your in. Google’s custom window chrome is the perfect indication to the user that they are in a special browsing mode.
Creating a HUD style slider
August 23, 2009
![]()
![]()
Someone recently requested I add a HUD style slider to the widget set, which I quickly added and is now available in the latest Mac Widgets for Java developer build. Creating a custom SliderUI delegate is pretty easy, as BasicSliderUI has all the right hooks. You can specify the thumb size, tick size, track bounds, thumb location and much more by overriding corresponding methods. This is a refreshing change from the complicated process of creating a custom scroll bar UI delegate (which I talked about in detail in part 1, part 2, and part 3 of the “Skinning a scroll bar” series).
As I’ve noted in previous HUD style component posts (here and here), you may notice a bit of redundant code between the posts. I’ve done this to keep the blog entries independent, but the actual Mac Widgets for Java code uses a utility class to do most of the HUD painting.
Finally, for this widget to look “correct”, it must be added to a HUD style window, which I talked about here.
public class HudSliderUI extends BasicSliderUI {
private static final int SLIDER_KNOB_WIDTH = 11;
private static final int SLIDER_KNOB_HEIGHT_NO_TICKS = 11;
private static final int SLIDER_KNOB_HEIGHT_WITH_TICKS = 13;
private static final int TRACK_HEIGHT = 4;
private static final Color TRACK_BACKGROUND_COLOR = new Color(143, 147, 144, 100);
private static final Color TRACK_BORDER_COLOR = new Color(255, 255, 255, 200);
private static final Color TOP_SLIDER_KNOB_COLOR = new Color(0x555555);
private static final Color BOTTOM_SLIDER_KNOB_COLOR = new Color(0x393939);
private static final Color TOP_SLIDER_KNOB_PRESSED_COLOR = new Color(0xb0b2b6);
private static final Color BOTTOM_SLIDER_KNOB_PRESSED_COLOR = new Color(0x86888b);
public static final Color BORDER_COLOR = new Color(0xc5c8cf);
private static final Color LIGHT_SHADOW_COLOR = new Color(0, 0, 0, 145);
private static final Color DARK_SHADOW_COLOR = new Color(0, 0, 0, 50);
private static final ShapeProvider NO_TICKS_SHAPE_PROVIDER =
createCircularSliderKnobShapeProvider();
private static final ShapeProvider TICKS_SHAPE_PROVIDER =
createPointedSliderKnobShapeProvider();
public HudSliderUI(JSlider b) {
super(b);
}
@Override
protected void installDefaults(JSlider slider) {
super.installDefaults(slider);
slider.setOpaque(false);
}
@Override
protected Dimension getThumbSize() {
int sliderKnobHeight = slider.getPaintTicks()
? SLIDER_KNOB_HEIGHT_WITH_TICKS : SLIDER_KNOB_HEIGHT_NO_TICKS;
return new Dimension(SLIDER_KNOB_WIDTH, sliderKnobHeight);
}
@Override
public void paintThumb(Graphics graphics) {
Paint paint = createSliderKnobButtonPaint(isDragging(), thumbRect.height);
ShapeProvider shapeProvider = slider.getPaintTicks()
? TICKS_SHAPE_PROVIDER : NO_TICKS_SHAPE_PROVIDER;
paintHudControlBackground((Graphics2D) graphics, thumbRect, shapeProvider,
paint);
}
@Override
public void paintTrack(Graphics graphics) {
Graphics2D graphics2d = (Graphics2D) graphics;
graphics2d.setRenderingHint(
RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
double trackY = slider.getHeight()/2.0 - TRACK_HEIGHT/2.0;
RoundRectangle2D track = new RoundRectangle2D.Double(
0, trackY, slider.getWidth()-1, TRACK_HEIGHT - 1, 4, 2);
graphics.setColor(TRACK_BACKGROUND_COLOR);
graphics2d.fill(track);
graphics2d.setColor(TRACK_BORDER_COLOR);
graphics2d.draw(track);
}
@Override
protected int getTickLength() {
return 5;
}
@Override
protected void calculateThumbLocation() {
super.calculateThumbLocation();
// if this is a horizontal style slider and we're drawing a pointy style thumb
// then shift the thumb down three pixels.
if ( slider.getOrientation() == JSlider.HORIZONTAL
&& slider.getPaintTicks()) {
thumbRect.y += 3;
} else {
// TODO handle vertical slider.
}
}
@Override
protected void calculateTickRect() {
super.calculateTickRect();
// if this is a horizontal style slider, shift the ticks down one pixel so that
// they aren't right up against the track.
if ( slider.getOrientation() == JSlider.HORIZONTAL ) {
tickRect.y += 1;
} else {
// TODO handle vertical slider.
}
}
@Override
protected void paintMajorTickForHorizSlider(Graphics g, Rectangle tickBounds, int x) {
g.setColor(Color.WHITE);
super.paintMajorTickForHorizSlider(g, tickBounds, x);
}
@Override
public void setThumbLocation(int x, int y) {
super.setThumbLocation(x, y);
// repaint the whole slider -- it's easier than trying to figure out
// whats dirty, especially since the thumb will be drawn outside of the
// thumbRect (the shadow part).
slider.repaint();
}
@Override
public void paintFocus(Graphics g) {
// don't paint focus.
}
private static Paint createSliderKnobButtonPaint(boolean isPressed, int height) {
// grab the top and bottom gradient colors based on the pressed state.
Color topColor = isPressed
? TOP_SLIDER_KNOB_PRESSED_COLOR : TOP_SLIDER_KNOB_COLOR;
Color bottomColor = isPressed
? BOTTOM_SLIDER_KNOB_PRESSED_COLOR : BOTTOM_SLIDER_KNOB_COLOR;
// compenstate for the two pixel shadow drawn below the slider thumb.
int bottomY = height - 2;
return new GradientPaint(0, 0, topColor, 0, bottomY, bottomColor);
}
/**
* Creates a simple circle.
*/
private static ShapeProvider createCircularSliderKnobShapeProvider() {
return new ShapeProvider() {
public Shape createShape(double x, double y, double width, double height) {
return new Ellipse2D.Double(x, y, width, height);
}
};
}
/**
* Cerates a pointy slider thumb shape that looks roughly like this:
* +----+
* / \
* + +
* | |
* + +
* \ /
* \/
*/
private static ShapeProvider createPointedSliderKnobShapeProvider() {
return new ShapeProvider() {
public Shape createShape(double x, double y, double width, double height) {
float xFloat = (float) x;
float yFloat = (float) y;
float widthFloat = (float) width;
float heightFloat = (float) height;
// draw the thumb shape based on the given height and width.
GeneralPath path = new GeneralPath();
// move in two pixels so that we can curve down to the next point.
path.moveTo(xFloat + 2.0f, yFloat);
// curve down to the second point.
path.curveTo(xFloat + 0.25f, yFloat + 0.25f, xFloat - 0.25f,
yFloat + 2.0f, xFloat, yFloat + 2.0f);
// move straight down to the next point.
path.lineTo(xFloat, yFloat + heightFloat/1.60f);
// move down and right to form the left half of the pointy section.
path.lineTo(xFloat + widthFloat/2, yFloat + heightFloat);
// move up and right to form the right half of the pointy section.
path.lineTo(xFloat + widthFloat, yFloat + heightFloat/1.60f);
// move straight up to the point to curve from.
path.lineTo(xFloat + widthFloat, yFloat + 2.0f);
// curve up and right to the top of the thumb.
path.curveTo(xFloat + widthFloat - 0.25f, yFloat + 2.0f,
xFloat + widthFloat - 0.25f, yFloat + 0.25f,
xFloat + widthFloat - 2.0f, yFloat);
path.closePath();
return path;
}
};
}
/**
* Paints a HUD style background in the given shape. This includes a drop shadow
* which will be drawn under the shape to be painted. The shadow will be draw
* outside the given bounds.
* @param graphics the {@code Graphics2D} context to draw in.
* @param bounds the bounds to paint in.
* @param shapeProvider the delegate to request the {@link Shape} from.
* @param paint the {@link Paint} to use to fill the {@code Shape}.
*/
public static void paintHudControlBackground(
Graphics2D graphics, Rectangle bounds, ShapeProvider shapeProvider,
Paint paint) {
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
int x = bounds.x;
int y = bounds.y;
int width = bounds.width;
int height = bounds.height;
// paint the first (further away) part of the drop shadow.
graphics.setColor(LIGHT_SHADOW_COLOR);
graphics.draw(shapeProvider.createShape(x, y, width - 1, height));
// paint the second (closer) part of the drop shadow.
graphics.setColor(DARK_SHADOW_COLOR);
graphics.draw(shapeProvider.createShape(x, y, width - 1, height + 1));
// fill the HUD shape.
graphics.setPaint(paint);
graphics.fill(shapeProvider.createShape(x, y + 1, width, height - 1));
// stroke the HUD shape.
graphics.setColor(BORDER_COLOR);
graphics.draw(shapeProvider.createShape(x, y, width - 1, height - 1));
}
/**
* An interface for specifying a shape to paint and draw a drop shadown under.
*/
public interface ShapeProvider {
Shape createShape(double x, double y, double width, double height);
}
}
New Java client properties on Mac
August 22, 2009
![]()
![]()
There are a few new client properties on the Mac, that haven’t yet been documented (though I’ve been assured that they’re safe to use). The great thing about the way Apple has been using client properties on the Mac, is that they make it easy for you to get closer to being a great Mac app, while not breaking your fidelity on other platforms. They’re really great if you want to fit in on your target platform, whereas in Mac Widgets for Java, I’m aiming for always looking like a Mac app.
The screenshots above show the new client properties that give you access to SourceList style selection painters (demo’d in the source code below). Note that these painters accurately pick up whether the user is using Aqua or Graphite — that’s a big bonus.
Here’s a listing of the new client properties:
List.sourceListBackgroundPainter List.sourceListSelectionBackgroundPainter List.sourceListFocusedSelectionBackgroundPainter List.evenRowBackgroundPainter List.oddRowBackgroundPainter
Here’s a little bit of code that puts a few of the new SourceList client properties to work (seen above):
public class NewClientProperties {
/**
* Create a SourceList style JList.
*/
private static JList createMacSourceList() {
JList list = new SourceList();
// install a custom renderer that wraps the already installed renderer.
list.setCellRenderer(new CustomListCellRenderer(list.getCellRenderer()));
return list;
}
/**
* A custom JList that renders like a Mac SourceList.
*/
public static class SourceList extends JList {
public SourceList() {
// make the component non-opaque so that we can paint the background in
// paintComponent.
setOpaque(false);
}
@Override
protected void paintComponent(Graphics g) {
// paint the background of the component using the special Mac border
// painter.
Border backgroundPainter =
UIManager.getBorder("List.sourceListBackgroundPainter");
backgroundPainter.paintBorder(this, g, 0, 0, getWidth(), getHeight());
super.paintComponent(g);
}
}
/**
* A custom ListCellRenderrer that wraps a delegate renderer.
*/
public static class CustomListCellRenderer extends JPanel
implements ListCellRenderer {
private ListCellRenderer fDelegate;
private boolean fIsSelected;
private boolean fIsFocused;
public CustomListCellRenderer(ListCellRenderer delegate) {
this.setOpaque(false);
this.setLayout(new BorderLayout());
this.setBorder(BorderFactory.createEmptyBorder(1,5,1,5));
fDelegate = delegate;
}
public Component getListCellRendererComponent(
JList list, Object value, int index,boolean isSelected,
boolean cellHasFocus) {
this.removeAll();
// remember the isSelected and cellHasFocus state so that we can use those
// values in the paintComponent method.
fIsSelected = isSelected;
fIsFocused = cellHasFocus;
// call the delegate renderer
JComponent component = (JComponent) fDelegate.getListCellRendererComponent(
list, value, index, isSelected, false);
// make the delegate rendere non-opqaue so that the background shows through.
component.setOpaque(false);
this.add(component, BorderLayout.CENTER);
return this;
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
// if the item was selected, then paint the custom Mac selection background.
if (fIsSelected) {
Border backgroundPainter = fIsFocused
? UIManager.getBorder("List.sourceListFocusedSelectionBackgroundPainter")
: UIManager.getBorder("List.sourceListSelectionBackgroundPainter");
backgroundPainter.paintBorder(this, g, 0, 0, getWidth(), getHeight());
}
}
}
public static void main(String[] args) {
JList list = createMacSourceList();
list.setListData(new String[]{
"BMW", "Chevy", "Dodge", "Infiniti", "Nissan", "Porsche"});
JScrollPane scrollPane = new JScrollPane(list);
scrollPane.setBorder(BorderFactory.createEmptyBorder());
JFrame frame = new JFrame();
frame.add(scrollPane);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setSize(200,200);
frame.setLocationRelativeTo(null);
frame.setVisible(true);
}
}
Creating a separated JList using Glazed Lists
August 5, 2009
![]()
I’ve been espousing the power of Glazed Lists for a long time, and won’t be stopping any time soon! I recently needed a JList that visually grouped items into categories. GL made this super simple.
Here’s how you can create a SeparatorList, which will auto-magically insert separators into your JList for you. We’ll create a simple list of items that we want grouped by their first letter.
public class SeparatorListTest {
/**
* Creates a {@link Comparator} that compares the first letter of two given strings.
*/
private static Comparator createComparator() {
return new Comparator() {
public int compare(String stringOne, String stringTwo) {
return stringOne.substring(0,1).compareTo(stringTwo.substring(0,1));
}
};
}
/**
* Creates a renderer that can render both separators and regular items.
*/
private static ListCellRenderer createListCellRenderer() {
return new DefaultListCellRenderer() {
@Override
public Component getListCellRendererComponent(
JList list, Object value, int index, boolean isSelected,
boolean cellHasFocus) {
// call the super renderer to take care of setting the foreground and
// background colors.
JLabel label = (JLabel) super.getListCellRendererComponent(
list, value, index, isSelected, cellHasFocus);
// if the item being renderered is a separator, then bold it, and shift
// in slightly.
// else if the item being rendered is an actual list item, make it plain
// and shift it in more.
if (value instanceof SeparatorList.Separator) {
SeparatorList.Separator separator = (SeparatorList.Separator) value;
label.setText(separator.getGroup().get(0).toString().substring(0,1));
label.setFont(label.getFont().deriveFont(Font.BOLD));
label.setBorder(BorderFactory.createEmptyBorder(0,5,0,0));
} else {
label.setFont(label.getFont().deriveFont(Font.PLAIN));
label.setBorder(BorderFactory.createEmptyBorder(0,15,0,0));
}
return label;
}
};
}
public static void main(String[] args) {
// create a list of items.
EventList rawList = GlazedLists.eventListOf(
"apple", "appricot", "acorn", "blueberry", "coconut", "chesnut", "grape");
// create a SeparatorList based on the raw list of items using the "first-letter"
// comparator to group them.
SeparatorList separatorList =
new SeparatorList(rawList, createComparator(), 0, 1000);
JList list = new JList(new EventListModel(separatorList));
list.setCellRenderer(createListCellRenderer());
JScrollPane scrollPane = new JScrollPane(list);
scrollPane.setBorder(null);
JFrame frame = new JFrame();
frame.add(scrollPane, BorderLayout.CENTER);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setSize(200,200);
frame.setLocationRelativeTo(null);
frame.setVisible(true);
}
}