Creating a Whatsapp Clone - Part V - Transcript.pdf
1. Creating a WhatsApp Clone - Part V
Now that we implemented the model code and the lifecycle code we are almost finished. We are down to UI code and CSS.
2. public class MainForm extends Form {
private Tabs tabs = new Tabs();
private CameraKit ck;
private Container chats;
private Container status;
private Container calls;
private static MainForm instance;
public MainForm() {
super("WhatsApp Clone", new BorderLayout());
instance = this;
add(CENTER, tabs);
tabs.hideTabs();
ck = CameraKit.create();
tabs.addTab("", createCameraView());
tabs.addTab("", createChatsContainer());
tabs.addTab("", createStatusContainer());
tabs.addTab("", createCallsContainer());
MainForm
We start with the main form which covers the list of chat elements.
As is the case with all the forms in this app we derive from Form for simplicity.
3. public class MainForm extends Form {
private Tabs tabs = new Tabs();
private CameraKit ck;
private Container chats;
private Container status;
private Container calls;
private static MainForm instance;
public MainForm() {
super("WhatsApp Clone", new BorderLayout());
instance = this;
add(CENTER, tabs);
tabs.hideTabs();
ck = CameraKit.create();
tabs.addTab("", createCameraView());
tabs.addTab("", createChatsContainer());
tabs.addTab("", createStatusContainer());
tabs.addTab("", createCallsContainer());
MainForm
The main body of the form is a tabs component that allows us to switch between camera, status and calls
4. public class MainForm extends Form {
private Tabs tabs = new Tabs();
private CameraKit ck;
private Container chats;
private Container status;
private Container calls;
private static MainForm instance;
public MainForm() {
super("WhatsApp Clone", new BorderLayout());
instance = this;
add(CENTER, tabs);
tabs.hideTabs();
ck = CameraKit.create();
tabs.addTab("", createCameraView());
tabs.addTab("", createChatsContainer());
tabs.addTab("", createStatusContainer());
tabs.addTab("", createCallsContainer());
MainForm
Camera kit is used to implement the camera UI but due to a regression in the native library this code is currently commented out.
5. public class MainForm extends Form {
private Tabs tabs = new Tabs();
private CameraKit ck;
private Container chats;
private Container status;
private Container calls;
private static MainForm instance;
public MainForm() {
super("WhatsApp Clone", new BorderLayout());
instance = this;
add(CENTER, tabs);
tabs.hideTabs();
ck = CameraKit.create();
tabs.addTab("", createCameraView());
tabs.addTab("", createChatsContainer());
tabs.addTab("", createStatusContainer());
tabs.addTab("", createCallsContainer());
MainForm
These are the 3 tab containers, we make use of them in the scrolling logic later
6. public class MainForm extends Form {
private Tabs tabs = new Tabs();
private CameraKit ck;
private Container chats;
private Container status;
private Container calls;
private static MainForm instance;
public MainForm() {
super("WhatsApp Clone", new BorderLayout());
instance = this;
add(CENTER, tabs);
tabs.hideTabs();
ck = CameraKit.create();
tabs.addTab("", createCameraView());
tabs.addTab("", createChatsContainer());
tabs.addTab("", createStatusContainer());
tabs.addTab("", createCallsContainer());
MainForm
The main form is a singleton as we need a way to refresh it when we are in a different form
7. private Container status;
private Container calls;
private static MainForm instance;
public MainForm() {
super("WhatsApp Clone", new BorderLayout());
instance = this;
add(CENTER, tabs);
tabs.hideTabs();
ck = CameraKit.create();
tabs.addTab("", createCameraView());
tabs.addTab("", createChatsContainer());
tabs.addTab("", createStatusContainer());
tabs.addTab("", createCallsContainer());
tabs.setSelectedIndex(1);
Toolbar tb = getToolbar();
tb.setTitleComponent(createTitleComponent(chats, status, calls));
setBackCommand("", null, e -> {
if(tabs.getSelectedIndex() != 1) {
tabs.setSelectedIndex(1);
} else {
MainForm
The form itself uses a border layout to place the tabs in the center. We also save the form instance for later use
8. private Container status;
private Container calls;
private static MainForm instance;
public MainForm() {
super("WhatsApp Clone", new BorderLayout());
instance = this;
add(CENTER, tabs);
tabs.hideTabs();
ck = CameraKit.create();
tabs.addTab("", createCameraView());
tabs.addTab("", createChatsContainer());
tabs.addTab("", createStatusContainer());
tabs.addTab("", createCallsContainer());
tabs.setSelectedIndex(1);
Toolbar tb = getToolbar();
tb.setTitleComponent(createTitleComponent(chats, status, calls));
setBackCommand("", null, e -> {
if(tabs.getSelectedIndex() != 1) {
tabs.setSelectedIndex(1);
} else {
MainForm
We hide the tabs, it generally means that
These aren't actually tabs, they are buttons we draw ourselves. The reason for that is the special animation we need in the title area
9. private Container status;
private Container calls;
private static MainForm instance;
public MainForm() {
super("WhatsApp Clone", new BorderLayout());
instance = this;
add(CENTER, tabs);
tabs.hideTabs();
ck = CameraKit.create();
tabs.addTab("", createCameraView());
tabs.addTab("", createChatsContainer());
tabs.addTab("", createStatusContainer());
tabs.addTab("", createCallsContainer());
tabs.setSelectedIndex(1);
Toolbar tb = getToolbar();
tb.setTitleComponent(createTitleComponent(chats, status, calls));
setBackCommand("", null, e -> {
if(tabs.getSelectedIndex() != 1) {
tabs.setSelectedIndex(1);
} else {
MainForm
We add the tabs and select the second one as the default as we don’t want to open with the camera view
10. tabs.addTab("", createCameraView());
tabs.addTab("", createChatsContainer());
tabs.addTab("", createStatusContainer());
tabs.addTab("", createCallsContainer());
tabs.setSelectedIndex(1);
Toolbar tb = getToolbar();
tb.setTitleComponent(createTitleComponent(chats, status, calls));
setBackCommand("", null, e -> {
if(tabs.getSelectedIndex() != 1) {
tabs.setSelectedIndex(1);
} else {
minimizeApplication();
}
});
}
public static MainForm getInstance() {
return instance;
}
private Container createCallsContainer() {
Container cnt = new Container(BoxLayout.y());
MainForm
Instead of using the title we use a title component which takes over the entire title area and lets us build whatever we want up there. I’ll discuss it more when covering
that method
11. tabs.addTab("", createCameraView());
tabs.addTab("", createChatsContainer());
tabs.addTab("", createStatusContainer());
tabs.addTab("", createCallsContainer());
tabs.setSelectedIndex(1);
Toolbar tb = getToolbar();
tb.setTitleComponent(createTitleComponent(chats, status, calls));
setBackCommand("", null, e -> {
if(tabs.getSelectedIndex() != 1) {
tabs.setSelectedIndex(1);
} else {
minimizeApplication();
}
});
}
public static MainForm getInstance() {
return instance;
}
private Container createCallsContainer() {
Container cnt = new Container(BoxLayout.y());
MainForm
The back command of the form respects the hardware back buttons in some devices and the Android native back arrow. Here we have custom behavior for the form. If
we are in a tab other than the first tab we need to return to that tab. Otherwise the app is minimized. This seems to be the behavior of the native app.
12. }
public static MainForm getInstance() {
return instance;
}
private Container createCallsContainer() {
Container cnt = new Container(BoxLayout.y());
calls = cnt;
cnt.setScrollableY(true);
MultiButton chat = new MultiButton("Person");
chat.setTextLine2("Date & time");
cnt.add(chat);
FloatingActionButton fab = FloatingActionButton.
createFAB(FontImage.MATERIAL_CALL);
return fab.bindFabToContainer(cnt);
}
private Container createStatusContainer() {
Container cnt = new Container(BoxLayout.y());
status = cnt;
cnt.setScrollableY(true);
MainForm
The calls container is a Y scrollable container.
13. }
public static MainForm getInstance() {
return instance;
}
private Container createCallsContainer() {
Container cnt = new Container(BoxLayout.y());
calls = cnt;
cnt.setScrollableY(true);
MultiButton chat = new MultiButton("Person");
chat.setTextLine2("Date & time");
cnt.add(chat);
FloatingActionButton fab = FloatingActionButton.
createFAB(FontImage.MATERIAL_CALL);
return fab.bindFabToContainer(cnt);
}
private Container createStatusContainer() {
Container cnt = new Container(BoxLayout.y());
status = cnt;
cnt.setScrollableY(true);
MainForm
This is simply a placeholder, I placed here a multibutton representing incoming/outgoing calls and a floating action button
14. FloatingActionButton fab = FloatingActionButton.
createFAB(FontImage.MATERIAL_CALL);
return fab.bindFabToContainer(cnt);
}
private Container createStatusContainer() {
Container cnt = new Container(BoxLayout.y());
status = cnt;
cnt.setScrollableY(true);
MultiButton chat = new MultiButton("My Status");
chat.setTextLine2("Tap to add status update");
cnt.add(chat);
FloatingActionButton fab = FloatingActionButton.
createFAB(FontImage.MATERIAL_CAMERA_ALT);
return fab.bindFabToContainer(cnt);
}
public void refreshChatsContainer() {
Server.fetchChatList(contacts -> {
chats.removeAll();
for(ChatContact c : contacts) {
MainForm
The same is true for the status container. This isn't an important part of the functionality with this tutorial
15. return fab.bindFabToContainer(cnt);
}
public void refreshChatsContainer() {
Server.fetchChatList(contacts -> {
chats.removeAll();
for(ChatContact c : contacts) {
MultiButton chat = new MultiButton(c.name.get());
chat.setTextLine2(c.tagline.get());
if(chat.getTextLine2() == null ||
chat.getTextLine2().length() == 0) {
chat.setTextLine2("...");
}
chat.setIcon(c.getLargeIcon());
chats.add(chat);
chat.addActionListener(e -> new ChatForm(c, this).show());
}
chats.revalidate();
});
}
private Container createChatsContainer() {
MainForm
You might recall that we invoke this method from the main UI to refresh the ongoing chat status.
16. return fab.bindFabToContainer(cnt);
}
public void refreshChatsContainer() {
Server.fetchChatList(contacts -> {
chats.removeAll();
for(ChatContact c : contacts) {
MultiButton chat = new MultiButton(c.name.get());
chat.setTextLine2(c.tagline.get());
if(chat.getTextLine2() == null ||
chat.getTextLine2().length() == 0) {
chat.setTextLine2("...");
}
chat.setIcon(c.getLargeIcon());
chats.add(chat);
chat.addActionListener(e -> new ChatForm(c, this).show());
}
chats.revalidate();
});
}
private Container createChatsContainer() {
MainForm
We fetch up to date data from the storage. This is an asynchronous call that returns on the EDT so the rest of the code goes into the lambda.
17. return fab.bindFabToContainer(cnt);
}
public void refreshChatsContainer() {
Server.fetchChatList(contacts -> {
chats.removeAll();
for(ChatContact c : contacts) {
MultiButton chat = new MultiButton(c.name.get());
chat.setTextLine2(c.tagline.get());
if(chat.getTextLine2() == null ||
chat.getTextLine2().length() == 0) {
chat.setTextLine2("...");
}
chat.setIcon(c.getLargeIcon());
chats.add(chat);
chat.addActionListener(e -> new ChatForm(c, this).show());
}
chats.revalidate();
});
}
private Container createChatsContainer() {
MainForm
We remove the old content as we’ll just re-add it
18. return fab.bindFabToContainer(cnt);
}
public void refreshChatsContainer() {
Server.fetchChatList(contacts -> {
chats.removeAll();
for(ChatContact c : contacts) {
MultiButton chat = new MultiButton(c.name.get());
chat.setTextLine2(c.tagline.get());
if(chat.getTextLine2() == null ||
chat.getTextLine2().length() == 0) {
chat.setTextLine2("...");
}
chat.setIcon(c.getLargeIcon());
chats.add(chat);
chat.addActionListener(e -> new ChatForm(c, this).show());
}
chats.revalidate();
});
}
private Container createChatsContainer() {
MainForm
We loop over the contacts and for every new contact we create a chat multi-button with the given name
19. return fab.bindFabToContainer(cnt);
}
public void refreshChatsContainer() {
Server.fetchChatList(contacts -> {
chats.removeAll();
for(ChatContact c : contacts) {
MultiButton chat = new MultiButton(c.name.get());
chat.setTextLine2(c.tagline.get());
if(chat.getTextLine2() == null ||
chat.getTextLine2().length() == 0) {
chat.setTextLine2("...");
}
chat.setIcon(c.getLargeIcon());
chats.add(chat);
chat.addActionListener(e -> new ChatForm(c, this).show());
}
chats.revalidate();
});
}
private Container createChatsContainer() {
MainForm
If there’s a tagline defined we set that tagline. We also use the large icon for that person
20. return fab.bindFabToContainer(cnt);
}
public void refreshChatsContainer() {
Server.fetchChatList(contacts -> {
chats.removeAll();
for(ChatContact c : contacts) {
MultiButton chat = new MultiButton(c.name.get());
chat.setTextLine2(c.tagline.get());
if(chat.getTextLine2() == null ||
chat.getTextLine2().length() == 0) {
chat.setTextLine2("...");
}
chat.setIcon(c.getLargeIcon());
chats.add(chat);
chat.addActionListener(e -> new ChatForm(c, this).show());
}
chats.revalidate();
});
}
private Container createChatsContainer() {
MainForm
If the button is clicked we show the chat form for this user
21. chat.addActionListener(e -> new ChatForm(c, this).show());
}
chats.revalidate();
});
}
private Container createChatsContainer() {
chats = new Container(BoxLayout.y());
chats.setScrollableY(true);
refreshChatsContainer();
FloatingActionButton fab = FloatingActionButton.
createFAB(FontImage.MATERIAL_CHAT);
fab.addActionListener(e -> new NewMessageForm().show());
return fab.bindFabToContainer(chats);
}
private Container createCameraView() {
if(ck != null) {
Container cameraCnt = new Container(new LayeredLayout());
MainForm
The chats container is the same as the other containers we saw but it’s actually fully implemented
22. chat.addActionListener(e -> new ChatForm(c, this).show());
}
chats.revalidate();
});
}
private Container createChatsContainer() {
chats = new Container(BoxLayout.y());
chats.setScrollableY(true);
refreshChatsContainer();
FloatingActionButton fab = FloatingActionButton.
createFAB(FontImage.MATERIAL_CHAT);
fab.addActionListener(e -> new NewMessageForm().show());
return fab.bindFabToContainer(chats);
}
private Container createCameraView() {
if(ck != null) {
Container cameraCnt = new Container(new LayeredLayout());
MainForm
It invokes the refresh chats container method we previously saw in order to fill up the container
23. chat.addActionListener(e -> new ChatForm(c, this).show());
}
chats.revalidate();
});
}
private Container createChatsContainer() {
chats = new Container(BoxLayout.y());
chats.setScrollableY(true);
refreshChatsContainer();
FloatingActionButton fab = FloatingActionButton.
createFAB(FontImage.MATERIAL_CHAT);
fab.addActionListener(e -> new NewMessageForm().show());
return fab.bindFabToContainer(chats);
}
private Container createCameraView() {
if(ck != null) {
Container cameraCnt = new Container(new LayeredLayout());
MainForm
The floating action button here is actually implemented by showing the new message form
24. }
private Container createCameraView() {
if(ck != null) {
Container cameraCnt = new Container(new LayeredLayout());
tabs.addSelectionListener((oldSelected, newSelected) -> {
if(newSelected == 0) {
//ck.start();
//cameraCnt.add(ck.getView());
getToolbar().setHidden(true);
} else {
if(oldSelected == 0) {
//cameraCnt.removeAll();
//ck.stop();
getToolbar().setHidden(false);
}
}
});
return cameraCnt;
}
return BorderLayout.center(new Label("Camera Unsupported"));
}
MainForm
Camera support is currently commented out due to a regression in the native library. However, the concept is relatively simple. We use the tab selection listener to
activate the camera as we need it
25. }
return BorderLayout.center(new Label("Camera Unsupported"));
}
private void showOverflowMenu() {
Button newGroup = new Button("New group", "Command");
Button newBroadcast = new Button("New broadcast", "Command");
Button whatsappWeb = new Button("WhatsApp Web", "Command");
Button starred = new Button("Starred Messages", "Command");
Button settings = new Button("Settings", "Command");
Container cnt = BoxLayout.encloseY(newGroup, newBroadcast,
whatsappWeb, starred, settings);
cnt.setUIID("CommandList");
Dialog dlg = new Dialog(new BorderLayout());
dlg.setDialogUIID("Container");
dlg.add(CENTER, cnt);
dlg.setDisposeWhenPointerOutOfBounds(true);
dlg.setTransitionInAnimator(CommonTransitions.createEmpty());
dlg.setTransitionOutAnimator(CommonTransitions.createEmpty());
dlg.setBackCommand("", null, e -> dlg.dispose());
int top = getUIManager().getComponentStyle("StatusBar").
getVerticalPadding();
setTintColor(0);
int bottom = getHeight() - cnt.getPreferredH() - top -
MainForm
The overflow menu is normally implemented in the toolbar but since I wanted more control over the toolbar area I chose to implement i manually in the code.
26. }
return BorderLayout.center(new Label("Camera Unsupported"));
}
private void showOverflowMenu() {
Button newGroup = new Button("New group", "Command");
Button newBroadcast = new Button("New broadcast", "Command");
Button whatsappWeb = new Button("WhatsApp Web", "Command");
Button starred = new Button("Starred Messages", "Command");
Button settings = new Button("Settings", "Command");
Container cnt = BoxLayout.encloseY(newGroup, newBroadcast,
whatsappWeb, starred, settings);
cnt.setUIID("CommandList");
Dialog dlg = new Dialog(new BorderLayout());
dlg.setDialogUIID("Container");
dlg.add(CENTER, cnt);
dlg.setDisposeWhenPointerOutOfBounds(true);
dlg.setTransitionInAnimator(CommonTransitions.createEmpty());
dlg.setTransitionOutAnimator(CommonTransitions.createEmpty());
dlg.setBackCommand("", null, e -> dlg.dispose());
int top = getUIManager().getComponentStyle("StatusBar").
getVerticalPadding();
setTintColor(0);
int bottom = getHeight() - cnt.getPreferredH() - top -
MainForm
I used buttons with the Command UIID and a Container with the CommandList UIID to create this UI. I used buttons with the Command UIID and a Container with the
CommandList UIID to create this UI. I’ll discuss the CSS that created this in the next lesson.
27. Button whatsappWeb = new Button("WhatsApp Web", "Command");
Button starred = new Button("Starred Messages", "Command");
Button settings = new Button("Settings", "Command");
Container cnt = BoxLayout.encloseY(newGroup, newBroadcast,
whatsappWeb, starred, settings);
cnt.setUIID("CommandList");
Dialog dlg = new Dialog(new BorderLayout());
dlg.setDialogUIID("Container");
dlg.add(CENTER, cnt);
dlg.setDisposeWhenPointerOutOfBounds(true);
dlg.setTransitionInAnimator(CommonTransitions.createEmpty());
dlg.setTransitionOutAnimator(CommonTransitions.createEmpty());
dlg.setBackCommand("", null, e -> dlg.dispose());
int top = getUIManager().getComponentStyle("StatusBar").
getVerticalPadding();
setTintColor(0);
int bottom = getHeight() - cnt.getPreferredH() - top -
cnt.getUnselectedStyle().getVerticalPadding() -
cnt.getUnselectedStyle().getVerticalMargins();
int w = getWidth();
int left = w - cnt.getPreferredW() -
cnt.getUnselectedStyle().getHorizontalPadding() -
cnt.getUnselectedStyle().getHorizontalMargins();
dlg.show(top, bottom, left, 0);
MainForm
I create a transparent dialog by giving it the Container UIID. I place the menu in the center
28. Button whatsappWeb = new Button("WhatsApp Web", "Command");
Button starred = new Button("Starred Messages", "Command");
Button settings = new Button("Settings", "Command");
Container cnt = BoxLayout.encloseY(newGroup, newBroadcast,
whatsappWeb, starred, settings);
cnt.setUIID("CommandList");
Dialog dlg = new Dialog(new BorderLayout());
dlg.setDialogUIID("Container");
dlg.add(CENTER, cnt);
dlg.setDisposeWhenPointerOutOfBounds(true);
dlg.setTransitionInAnimator(CommonTransitions.createEmpty());
dlg.setTransitionOutAnimator(CommonTransitions.createEmpty());
dlg.setBackCommand("", null, e -> dlg.dispose());
int top = getUIManager().getComponentStyle("StatusBar").
getVerticalPadding();
setTintColor(0);
int bottom = getHeight() - cnt.getPreferredH() - top -
cnt.getUnselectedStyle().getVerticalPadding() -
cnt.getUnselectedStyle().getVerticalMargins();
int w = getWidth();
int left = w - cnt.getPreferredW() -
cnt.getUnselectedStyle().getHorizontalPadding() -
cnt.getUnselectedStyle().getHorizontalMargins();
dlg.show(top, bottom, left, 0);
MainForm
The dialog has no transition and disposed if the user taps outside of it or uses the back button
29. whatsappWeb, starred, settings);
cnt.setUIID("CommandList");
Dialog dlg = new Dialog(new BorderLayout());
dlg.setDialogUIID("Container");
dlg.add(CENTER, cnt);
dlg.setDisposeWhenPointerOutOfBounds(true);
dlg.setTransitionInAnimator(CommonTransitions.createEmpty());
dlg.setTransitionOutAnimator(CommonTransitions.createEmpty());
dlg.setBackCommand("", null, e -> dlg.dispose());
int top = getUIManager().getComponentStyle("StatusBar").
getVerticalPadding();
setTintColor(0);
int bottom = getHeight() - cnt.getPreferredH() - top -
cnt.getUnselectedStyle().getVerticalPadding() -
cnt.getUnselectedStyle().getVerticalMargins();
int w = getWidth();
int left = w - cnt.getPreferredW() -
cnt.getUnselectedStyle().getHorizontalPadding() -
cnt.getUnselectedStyle().getHorizontalMargins();
dlg.show(top, bottom, left, 0);
}
private Container createTitleComponent(Container... scrollables) {
Label title = new Label("WhatsApp", "Title");
MainForm
This disables the default darkening of the form when a dialog is shown
30. whatsappWeb, starred, settings);
cnt.setUIID("CommandList");
Dialog dlg = new Dialog(new BorderLayout());
dlg.setDialogUIID("Container");
dlg.add(CENTER, cnt);
dlg.setDisposeWhenPointerOutOfBounds(true);
dlg.setTransitionInAnimator(CommonTransitions.createEmpty());
dlg.setTransitionOutAnimator(CommonTransitions.createEmpty());
dlg.setBackCommand("", null, e -> dlg.dispose());
int top = getUIManager().getComponentStyle("StatusBar").
getVerticalPadding();
setTintColor(0);
int bottom = getHeight() - cnt.getPreferredH() - top -
cnt.getUnselectedStyle().getVerticalPadding() -
cnt.getUnselectedStyle().getVerticalMargins();
int w = getWidth();
int left = w - cnt.getPreferredW() -
cnt.getUnselectedStyle().getHorizontalPadding() -
cnt.getUnselectedStyle().getHorizontalMargins();
dlg.show(top, bottom, left, 0);
}
private Container createTitleComponent(Container... scrollables) {
Label title = new Label("WhatsApp", "Title");
MainForm
This version of the show method places the dialog with a fixed distance from the edges. We give it a small margin on the top to take the status bar into account. Then use
left and bottom margin to push the dialog to the top right side.
This gives us a lot of flexibility and allows us to show the dialog in any way we want.
31. cnt.getUnselectedStyle().getVerticalPadding() -
cnt.getUnselectedStyle().getVerticalMargins();
int w = getWidth();
int left = w - cnt.getPreferredW() -
cnt.getUnselectedStyle().getHorizontalPadding() -
cnt.getUnselectedStyle().getHorizontalMargins();
dlg.show(top, bottom, left, 0);
}
private Container createTitleComponent(Container... scrollables) {
Label title = new Label("WhatsApp", "Title");
Container titleArea;
if(title.getUnselectedStyle().getAlignment() == LEFT) {
titleArea = BorderLayout.center(title);
} else {
// for iOS we want the title to center properly
titleArea = BorderLayout.centerAbsolute(title);
}
Button search = new Button("", FontImage.MATERIAL_SEARCH, "Title");
Button overflow = new Button("", FontImage.MATERIAL_MORE_VERT,
"Title");
overflow.addActionListener(e -> showOverflowMenu());
titleArea.add(EAST, GridLayout.encloseIn(2, search, overflow));
MainForm
This method creates the title component for the form
which is this region. The method accepts the scrollable containers in the tabs container. This allows us to track scrolling and seamlessly fold the title area
32. cnt.getUnselectedStyle().getVerticalPadding() -
cnt.getUnselectedStyle().getVerticalMargins();
int w = getWidth();
int left = w - cnt.getPreferredW() -
cnt.getUnselectedStyle().getHorizontalPadding() -
cnt.getUnselectedStyle().getHorizontalMargins();
dlg.show(top, bottom, left, 0);
}
private Container createTitleComponent(Container... scrollables) {
Label title = new Label("WhatsApp", "Title");
Container titleArea;
if(title.getUnselectedStyle().getAlignment() == LEFT) {
titleArea = BorderLayout.center(title);
} else {
// for iOS we want the title to center properly
titleArea = BorderLayout.centerAbsolute(title);
}
Button search = new Button("", FontImage.MATERIAL_SEARCH, "Title");
Button overflow = new Button("", FontImage.MATERIAL_MORE_VERT,
"Title");
overflow.addActionListener(e -> showOverflowMenu());
titleArea.add(EAST, GridLayout.encloseIn(2, search, overflow));
MainForm
The title itself is just a label with the “Title” UIID. It’s placed in the center of the title area border layout. If we are on iOS we want the title to be centered, in that case we
need to use the center version of the border layout. The reason for this is that center alignment doesn’t know about the full layout and would center based on available
space. It would ignore the search and overflow buttons on the right when centering since it isn’t aware of other components.
However, using the center alignment and placing these buttons in the east solves that problem and gives us the correct title position.
33. if(title.getUnselectedStyle().getAlignment() == LEFT) {
titleArea = BorderLayout.center(title);
} else {
// for iOS we want the title to center properly
titleArea = BorderLayout.centerAbsolute(title);
}
Button search = new Button("", FontImage.MATERIAL_SEARCH, "Title");
Button overflow = new Button("", FontImage.MATERIAL_MORE_VERT,
"Title");
overflow.addActionListener(e -> showOverflowMenu());
titleArea.add(EAST, GridLayout.encloseIn(2, search, overflow));
ButtonGroup bg = new ButtonGroup();
RadioButton camera = RadioButton.createToggle("", bg);
camera.setUIID("SubTitle");
FontImage.setMaterialIcon(camera, FontImage.MATERIAL_CAMERA_ALT);
RadioButton chats = RadioButton.createToggle("Chats", bg);
RadioButton status = RadioButton.createToggle("Status", bg);
RadioButton calls = RadioButton.createToggle("Calls", bg);
chats.setUIID("SubTitle");
status.setUIID("SubTitle");
calls.setUIID("SubTitle");
RadioButton[] buttons = new RadioButton[] {
MainForm
The search and overflow commands are just buttons with the “Title” UIID. We already discussed the showOverflowMenu() method so this should be pretty obvious. We
just place the two buttons in the grid. I chose not to use a Command as this might create a misalignment for this use case and wouldn’t have saved on the amount of
code I had to write.
34. Button search = new Button("", FontImage.MATERIAL_SEARCH, "Title");
Button overflow = new Button("", FontImage.MATERIAL_MORE_VERT,
"Title");
overflow.addActionListener(e -> showOverflowMenu());
titleArea.add(EAST, GridLayout.encloseIn(2, search, overflow));
ButtonGroup bg = new ButtonGroup();
RadioButton camera = RadioButton.createToggle("", bg);
camera.setUIID("SubTitle");
FontImage.setMaterialIcon(camera, FontImage.MATERIAL_CAMERA_ALT);
RadioButton chats = RadioButton.createToggle("Chats", bg);
RadioButton status = RadioButton.createToggle("Status", bg);
RadioButton calls = RadioButton.createToggle("Calls", bg);
chats.setUIID("SubTitle");
status.setUIID("SubTitle");
calls.setUIID("SubTitle");
RadioButton[] buttons = new RadioButton[] {
camera, chats, status, calls
};
TableLayout tb = new TableLayout(2, 4);
Container toggles = new Container(tb);
MainForm
These are the tabs for selecting camera, chat etc… They are just toggle buttons which in this case are classified as radio buttons. This means only one of the radio
buttons within the button group can be selected. We give them all the SubTitle UIID which again I’ll discuss in the next lesson.
35. chats.setUIID("SubTitle");
status.setUIID("SubTitle");
calls.setUIID("SubTitle");
RadioButton[] buttons = new RadioButton[] {
camera, chats, status, calls
};
TableLayout tb = new TableLayout(2, 4);
Container toggles = new Container(tb);
toggles.add(tb.createConstraint().widthPercentage(10), camera);
toggles.add(tb.createConstraint().widthPercentage(30), chats);
toggles.add(tb.createConstraint().widthPercentage(30), status);
toggles.add(tb.createConstraint().widthPercentage(30), calls);
Label whiteLine = new Label("", "SubTitleUnderline");
whiteLine.setShowEvenIfBlank(true);
toggles.add(tb.createConstraint(1, 1) ,whiteLine);
final Container finalTitle = titleArea;
for(int iter = 0 ; iter < buttons.length ; iter++) {
final int current = iter;
buttons[iter].addActionListener(e -> {
tabs.setSelectedIndex(current);
MainForm
We use table layout to place the tabs into the UI, this allows us to explicitly determine the width of the columns.
Notice that the table layout has 2 rows…
36. TableLayout tb = new TableLayout(2, 4);
Container toggles = new Container(tb);
toggles.add(tb.createConstraint().widthPercentage(10), camera);
toggles.add(tb.createConstraint().widthPercentage(30), chats);
toggles.add(tb.createConstraint().widthPercentage(30), status);
toggles.add(tb.createConstraint().widthPercentage(30), calls);
Label whiteLine = new Label("", "SubTitleUnderline");
whiteLine.setShowEvenIfBlank(true);
toggles.add(tb.createConstraint(1, 1) ,whiteLine);
final Container finalTitle = titleArea;
for(int iter = 0 ; iter < buttons.length ; iter++) {
final int current = iter;
buttons[iter].addActionListener(e -> {
tabs.setSelectedIndex(current);
whiteLine.remove();
toggles.add(tb.createConstraint(1, current) ,whiteLine);
finalTitle.setPreferredSize(null);
toggles.animateLayout(100);
});
}
MainForm
The second row of the table contains a white line using the “SideTitleUnderline” UIID. This line is placed in row one and column one so it’s under the chats entry. When
we move between the tabs this underline needs to animate to the new position.
37. Label whiteLine = new Label("", "SubTitleUnderline");
whiteLine.setShowEvenIfBlank(true);
toggles.add(tb.createConstraint(1, 1) ,whiteLine);
final Container finalTitle = titleArea;
for(int iter = 0 ; iter < buttons.length ; iter++) {
final int current = iter;
buttons[iter].addActionListener(e -> {
tabs.setSelectedIndex(current);
whiteLine.remove();
toggles.add(tb.createConstraint(1, current) ,whiteLine);
finalTitle.setPreferredSize(null);
toggles.animateLayout(100);
});
}
tabs.addSelectionListener((oldSelected, newSelected) -> {
if(!buttons[newSelected].isSelected()) {
finalTitle.setPreferredSize(null);
buttons[newSelected].setSelected(true);
whiteLine.remove();
toggles.add(tb.createConstraint(1, newSelected) ,whiteLine);
toggles.animateLayout(100);
MainForm
Here we bind listeners to all the four buttons mapping to each tab.
38. Label whiteLine = new Label("", "SubTitleUnderline");
whiteLine.setShowEvenIfBlank(true);
toggles.add(tb.createConstraint(1, 1) ,whiteLine);
final Container finalTitle = titleArea;
for(int iter = 0 ; iter < buttons.length ; iter++) {
final int current = iter;
buttons[iter].addActionListener(e -> {
tabs.setSelectedIndex(current);
whiteLine.remove();
toggles.add(tb.createConstraint(1, current) ,whiteLine);
finalTitle.setPreferredSize(null);
toggles.animateLayout(100);
});
}
tabs.addSelectionListener((oldSelected, newSelected) -> {
if(!buttons[newSelected].isSelected()) {
finalTitle.setPreferredSize(null);
buttons[newSelected].setSelected(true);
whiteLine.remove();
toggles.add(tb.createConstraint(1, newSelected) ,whiteLine);
toggles.animateLayout(100);
MainForm
When a button is clicked we select the appropriate tab
39. Label whiteLine = new Label("", "SubTitleUnderline");
whiteLine.setShowEvenIfBlank(true);
toggles.add(tb.createConstraint(1, 1) ,whiteLine);
final Container finalTitle = titleArea;
for(int iter = 0 ; iter < buttons.length ; iter++) {
final int current = iter;
buttons[iter].addActionListener(e -> {
tabs.setSelectedIndex(current);
whiteLine.remove();
toggles.add(tb.createConstraint(1, current) ,whiteLine);
finalTitle.setPreferredSize(null);
toggles.animateLayout(100);
});
}
tabs.addSelectionListener((oldSelected, newSelected) -> {
if(!buttons[newSelected].isSelected()) {
finalTitle.setPreferredSize(null);
buttons[newSelected].setSelected(true);
whiteLine.remove();
toggles.add(tb.createConstraint(1, newSelected) ,whiteLine);
toggles.animateLayout(100);
MainForm
The next two lines implement the underline animation effect that we see when we click a button. Notice how the line animates to the right tab button.
To achieve this we remove the current white line and add it back to the toggle container in the right position.
40. Label whiteLine = new Label("", "SubTitleUnderline");
whiteLine.setShowEvenIfBlank(true);
toggles.add(tb.createConstraint(1, 1) ,whiteLine);
final Container finalTitle = titleArea;
for(int iter = 0 ; iter < buttons.length ; iter++) {
final int current = iter;
buttons[iter].addActionListener(e -> {
tabs.setSelectedIndex(current);
whiteLine.remove();
toggles.add(tb.createConstraint(1, current) ,whiteLine);
finalTitle.setPreferredSize(null);
toggles.animateLayout(100);
});
}
tabs.addSelectionListener((oldSelected, newSelected) -> {
if(!buttons[newSelected].isSelected()) {
finalTitle.setPreferredSize(null);
buttons[newSelected].setSelected(true);
whiteLine.remove();
toggles.add(tb.createConstraint(1, newSelected) ,whiteLine);
toggles.animateLayout(100);
MainForm
We reset the height of the title in case it was shrunk during scrolling
41. Label whiteLine = new Label("", "SubTitleUnderline");
whiteLine.setShowEvenIfBlank(true);
toggles.add(tb.createConstraint(1, 1) ,whiteLine);
final Container finalTitle = titleArea;
for(int iter = 0 ; iter < buttons.length ; iter++) {
final int current = iter;
buttons[iter].addActionListener(e -> {
tabs.setSelectedIndex(current);
whiteLine.remove();
toggles.add(tb.createConstraint(1, current) ,whiteLine);
finalTitle.setPreferredSize(null);
toggles.animateLayout(100);
});
}
tabs.addSelectionListener((oldSelected, newSelected) -> {
if(!buttons[newSelected].isSelected()) {
finalTitle.setPreferredSize(null);
buttons[newSelected].setSelected(true);
whiteLine.remove();
toggles.add(tb.createConstraint(1, newSelected) ,whiteLine);
toggles.animateLayout(100);
MainForm
And we finally update the layout with an animation which performs the actual line move animation
42. whiteLine.remove();
toggles.add(tb.createConstraint(1, current) ,whiteLine);
finalTitle.setPreferredSize(null);
toggles.animateLayout(100);
});
}
tabs.addSelectionListener((oldSelected, newSelected) -> {
if(!buttons[newSelected].isSelected()) {
finalTitle.setPreferredSize(null);
buttons[newSelected].setSelected(true);
whiteLine.remove();
toggles.add(tb.createConstraint(1, newSelected) ,whiteLine);
toggles.animateLayout(100);
}
});
bindFolding(titleArea, titleArea.getPreferredH(), scrollables);
return BoxLayout.encloseY(titleArea, toggles);
}
private void bindFolding(Container titleArea, int titleHeight,
Container... scrollables) {
MainForm
The previous block updated the tab selection when we select a button. This block does the opposite. It updates the button selection when we swipe the tabs. It uses a
tab selection listener
46. tabs.addSelectionListener((oldSelected, newSelected) -> {
if(!buttons[newSelected].isSelected()) {
finalTitle.setPreferredSize(null);
buttons[newSelected].setSelected(true);
whiteLine.remove();
toggles.add(tb.createConstraint(1, newSelected) ,whiteLine);
toggles.animateLayout(100);
}
});
bindFolding(titleArea, titleArea.getPreferredH(), scrollables);
return BoxLayout.encloseY(titleArea, toggles);
}
private void bindFolding(Container titleArea, int titleHeight,
Container... scrollables) {
addPointerReleasedListener(e -> {
if(titleArea.getHeight() != titleHeight &&
titleArea.getHeight() != 0) {
if(titleHeight - titleArea.getHeight() > titleHeight / 2) {
titleArea.setPreferredSize(null);
MainForm
Finally we perform the animation of moving the underline between the tabs. Notice that this is almost identical to the previous animation code only in this case it’s
triggered by a dragging of the tabs instead of the button click event.
47. tabs.addSelectionListener((oldSelected, newSelected) -> {
if(!buttons[newSelected].isSelected()) {
finalTitle.setPreferredSize(null);
buttons[newSelected].setSelected(true);
whiteLine.remove();
toggles.add(tb.createConstraint(1, newSelected) ,whiteLine);
toggles.animateLayout(100);
}
});
bindFolding(titleArea, titleArea.getPreferredH(), scrollables);
return BoxLayout.encloseY(titleArea, toggles);
}
private void bindFolding(Container titleArea, int titleHeight,
Container... scrollables) {
addPointerReleasedListener(e -> {
if(titleArea.getHeight() != titleHeight &&
titleArea.getHeight() != 0) {
if(titleHeight - titleArea.getHeight() > titleHeight / 2) {
titleArea.setPreferredSize(null);
MainForm
The last two lines in this method are the bindFolding call which we will discuss soon and the box layout Y which wraps the two containers as one.
48. bindFolding(titleArea, titleArea.getPreferredH(), scrollables);
return BoxLayout.encloseY(titleArea, toggles);
}
private void bindFolding(Container titleArea, int titleHeight,
Container... scrollables) {
addPointerReleasedListener(e -> {
if(titleArea.getHeight() != titleHeight &&
titleArea.getHeight() != 0) {
if(titleHeight - titleArea.getHeight() > titleHeight / 2) {
titleArea.setPreferredSize(null);
} else {
titleArea.setPreferredH(0);
}
titleArea.getParent().animateLayout(100);
}
});
for(Container c : scrollables) {
c.addScrollListener((scrollX, scrollY, oldscrollX,
oldscrollY) -> {
// special case for tensile drag
if(scrollY <= 10) {
titleArea.setPreferredSize(null);
MainForm
The bindFolding method implements this animation of folding title. It's implemented by tracking pointer drag events and shrinking the title.
49. bindFolding(titleArea, titleArea.getPreferredH(), scrollables);
return BoxLayout.encloseY(titleArea, toggles);
}
private void bindFolding(Container titleArea, int titleHeight,
Container... scrollables) {
addPointerReleasedListener(e -> {
if(titleArea.getHeight() != titleHeight &&
titleArea.getHeight() != 0) {
if(titleHeight - titleArea.getHeight() > titleHeight / 2) {
titleArea.setPreferredSize(null);
} else {
titleArea.setPreferredH(0);
}
titleArea.getParent().animateLayout(100);
}
});
for(Container c : scrollables) {
c.addScrollListener((scrollX, scrollY, oldscrollX,
oldscrollY) -> {
// special case for tensile drag
if(scrollY <= 10) {
titleArea.setPreferredSize(null);
MainForm
When the pointer is released we need to check if the title shrunk enough to minimize or not enough so it would go back to the full size.
50. bindFolding(titleArea, titleArea.getPreferredH(), scrollables);
return BoxLayout.encloseY(titleArea, toggles);
}
private void bindFolding(Container titleArea, int titleHeight,
Container... scrollables) {
addPointerReleasedListener(e -> {
if(titleArea.getHeight() != titleHeight &&
titleArea.getHeight() != 0) {
if(titleHeight - titleArea.getHeight() > titleHeight / 2) {
titleArea.setPreferredSize(null);
} else {
titleArea.setPreferredH(0);
}
titleArea.getParent().animateLayout(100);
}
});
for(Container c : scrollables) {
c.addScrollListener((scrollX, scrollY, oldscrollX,
oldscrollY) -> {
// special case for tensile drag
if(scrollY <= 10) {
titleArea.setPreferredSize(null);
MainForm
If the title area height is different from the original height it means we are in the process of shrinking the title.
51. bindFolding(titleArea, titleArea.getPreferredH(), scrollables);
return BoxLayout.encloseY(titleArea, toggles);
}
private void bindFolding(Container titleArea, int titleHeight,
Container... scrollables) {
addPointerReleasedListener(e -> {
if(titleArea.getHeight() != titleHeight &&
titleArea.getHeight() != 0) {
if(titleHeight - titleArea.getHeight() > titleHeight / 2) {
titleArea.setPreferredSize(null);
} else {
titleArea.setPreferredH(0);
}
titleArea.getParent().animateLayout(100);
}
});
for(Container c : scrollables) {
c.addScrollListener((scrollX, scrollY, oldscrollX,
oldscrollY) -> {
// special case for tensile drag
if(scrollY <= 10) {
titleArea.setPreferredSize(null);
MainForm
In that case we need to decide whether the process is closer to the finish line or to the start
52. bindFolding(titleArea, titleArea.getPreferredH(), scrollables);
return BoxLayout.encloseY(titleArea, toggles);
}
private void bindFolding(Container titleArea, int titleHeight,
Container... scrollables) {
addPointerReleasedListener(e -> {
if(titleArea.getHeight() != titleHeight &&
titleArea.getHeight() != 0) {
if(titleHeight - titleArea.getHeight() > titleHeight / 2) {
titleArea.setPreferredSize(null);
} else {
titleArea.setPreferredH(0);
}
titleArea.getParent().animateLayout(100);
}
});
for(Container c : scrollables) {
c.addScrollListener((scrollX, scrollY, oldscrollX,
oldscrollY) -> {
// special case for tensile drag
if(scrollY <= 10) {
titleArea.setPreferredSize(null);
MainForm
If it’s less than half way to the height of the title we reset the preferred size of the title area. That means the title area will take up it’s original preferred size and grow back
to full height
53. bindFolding(titleArea, titleArea.getPreferredH(), scrollables);
return BoxLayout.encloseY(titleArea, toggles);
}
private void bindFolding(Container titleArea, int titleHeight,
Container... scrollables) {
addPointerReleasedListener(e -> {
if(titleArea.getHeight() != titleHeight &&
titleArea.getHeight() != 0) {
if(titleHeight - titleArea.getHeight() > titleHeight / 2) {
titleArea.setPreferredSize(null);
} else {
titleArea.setPreferredH(0);
}
titleArea.getParent().animateLayout(100);
}
});
for(Container c : scrollables) {
c.addScrollListener((scrollX, scrollY, oldscrollX,
oldscrollY) -> {
// special case for tensile drag
if(scrollY <= 10) {
titleArea.setPreferredSize(null);
MainForm
Otherwise we set the title area height to 0 so it’s effectively hidden
54. bindFolding(titleArea, titleArea.getPreferredH(), scrollables);
return BoxLayout.encloseY(titleArea, toggles);
}
private void bindFolding(Container titleArea, int titleHeight,
Container... scrollables) {
addPointerReleasedListener(e -> {
if(titleArea.getHeight() != titleHeight &&
titleArea.getHeight() != 0) {
if(titleHeight - titleArea.getHeight() > titleHeight / 2) {
titleArea.setPreferredSize(null);
} else {
titleArea.setPreferredH(0);
}
titleArea.getParent().animateLayout(100);
}
});
for(Container c : scrollables) {
c.addScrollListener((scrollX, scrollY, oldscrollX,
oldscrollY) -> {
// special case for tensile drag
if(scrollY <= 10) {
titleArea.setPreferredSize(null);
MainForm
Regardless of the choice we made above we show it using an animation
55. titleArea.setPreferredH(0);
}
titleArea.getParent().animateLayout(100);
}
});
for(Container c : scrollables) {
c.addScrollListener((scrollX, scrollY, oldscrollX,
oldscrollY) -> {
// special case for tensile drag
if(scrollY <= 10) {
titleArea.setPreferredSize(null);
return;
}
int diff = oldscrollY - scrollY;
if(diff > 0) {
if(titleArea.getHeight() < titleHeight) {
titleArea.setPreferredH(Math.min(titleHeight,
titleArea.getPreferredH() + diff));
titleArea.setHeight(titleArea.getPreferredH());
titleArea.getParent().revalidate();
}
} else {
if(diff < 0) {
if(titleArea.getHeight() > 0) {
MainForm
We detect the drag operation by binding a scroll listener to the three scrollable containers. I could have used pointer dragged listeners but they might generate too much
noise that isn't applicable.
56. titleArea.setPreferredH(0);
}
titleArea.getParent().animateLayout(100);
}
});
for(Container c : scrollables) {
c.addScrollListener((scrollX, scrollY, oldscrollX,
oldscrollY) -> {
// special case for tensile drag
if(scrollY <= 10) {
titleArea.setPreferredSize(null);
return;
}
int diff = oldscrollY - scrollY;
if(diff > 0) {
if(titleArea.getHeight() < titleHeight) {
titleArea.setPreferredH(Math.min(titleHeight,
titleArea.getPreferredH() + diff));
titleArea.setHeight(titleArea.getPreferredH());
titleArea.getParent().revalidate();
}
} else {
if(diff < 0) {
if(titleArea.getHeight() > 0) {
MainForm
I chose to make a special case for the tensile drag effect. The tensile effect is the iOS scroll behavior where a drag extends beyond the top most part then bounces back
like a rubber band. This can cause a problem with the logic below so I decided that any scroll position above 10 pixels should probably show the full title
57. if(scrollY <= 10) {
titleArea.setPreferredSize(null);
return;
}
int diff = oldscrollY - scrollY;
if(diff > 0) {
if(titleArea.getHeight() < titleHeight) {
titleArea.setPreferredH(Math.min(titleHeight,
titleArea.getPreferredH() + diff));
titleArea.setHeight(titleArea.getPreferredH());
titleArea.getParent().revalidate();
}
} else {
if(diff < 0) {
if(titleArea.getHeight() > 0) {
titleArea.setPreferredH(Math.max(0,
titleArea.getPreferredH() + diff));
titleArea.setHeight(titleArea.getPreferredH());
titleArea.getParent().revalidate();
}
}
}
});
MainForm
Now that all of that is out of the way we can calculate the direction of the scroll and shrink/grow the title area appropriately
58. if(scrollY <= 10) {
titleArea.setPreferredSize(null);
return;
}
int diff = oldscrollY - scrollY;
if(diff > 0) {
if(titleArea.getHeight() < titleHeight) {
titleArea.setPreferredH(Math.min(titleHeight,
titleArea.getPreferredH() + diff));
titleArea.setHeight(titleArea.getPreferredH());
titleArea.getParent().revalidate();
}
} else {
if(diff < 0) {
if(titleArea.getHeight() > 0) {
titleArea.setPreferredH(Math.max(0,
titleArea.getPreferredH() + diff));
titleArea.setHeight(titleArea.getPreferredH());
titleArea.getParent().revalidate();
}
}
}
});
MainForm
If the diff is larger than 0 then the title area should grow. We’re setting the preferred height to the diff plus the preferred height but we make sure not to cross the
maximum height value. We then revalidate to refresh the UI
59. }
int diff = oldscrollY - scrollY;
if(diff > 0) {
if(titleArea.getHeight() < titleHeight) {
titleArea.setPreferredH(Math.min(titleHeight,
titleArea.getPreferredH() + diff));
titleArea.setHeight(titleArea.getPreferredH());
titleArea.getParent().revalidate();
}
} else {
if(diff < 0) {
if(titleArea.getHeight() > 0) {
titleArea.setPreferredH(Math.max(0,
titleArea.getPreferredH() + diff));
titleArea.setHeight(titleArea.getPreferredH());
titleArea.getParent().revalidate();
}
}
}
});
}
}
MainForm
A negative diff is practically identical with the exception of making it 0 or larger instead of using the minimum value we use the max method. And with that the title folding
is implemented
60. }
} else {
if(diff < 0) {
if(titleArea.getHeight() > 0) {
titleArea.setPreferredH(Math.max(0,
titleArea.getPreferredH() + diff));
titleArea.setHeight(titleArea.getPreferredH());
titleArea.getParent().revalidate();
}
}
}
});
}
}
@Override
protected void initGlobalToolbar() {
Toolbar tb = new Toolbar();
tb.setTitleCentered(false);
setToolbar(tb);
}
}
MainForm
The one last method in the class is this. We use a custom toolbar that disables centered title. The centered title places the title area in the center of the UI and it doesn’t
work for folding. We need to disable it for this form so the title acts correctly on iOS.