The Comments 2.0 App is a Gluon code sample. For a full list of Gluon code samples, refer to the Gluon website.
In this tutorial, we’ll explain how to create the Comments 2.0 application that can be deployed on desktop, Android and iOS devices. It is a follow up of the Comments application tutorial. In case you haven’t followed it yet, check it out before starting this tutorial.
Before you start, make sure to check the list of prerequisites for each platform, and that you have installed the Gluon plugin for your IDE.
Code: The code for this project can be found in the samples repository at GitHub.
The sample is located under the directory comments2.0
. The reader can clone this repository or create the entire project from scratch, based on the following steps.
Creating the project
This project can be created from scratch using the Gluon plugin on your favourite IDE. But since this is a follow up of the Comments app, we’ll clone the original Comments sample instead and modify it to add new features.
If we want to have both projects deployed at the same time on mobile, we can’t have them with the same package name, so
we will refactor the packages to com.gluonhq.comments20.*
and the main class to Comments20
. Note this means also
modifying the constructor in the FXML files, and making the necessary adjustments in the build.gradle
,
AndroidManifest.xml
and Default-info.plist
files.
After making all those changes, run the application and make sure it works as expected.
Modifying the project
On top of Comments App, we are going to add user authentication: Comments can be created only by registered users of the app, so only the author of one comment can edit or delete it.
The model
Let’s change in the first place the Comment
model we had with two String
fields for author and content, in favor of
StringProperty
wrappers, while also including the url of the user picture and his network id.
public class Comment {
private final StringProperty author = new SimpleStringProperty();
private final StringProperty content = new SimpleStringProperty();
private final StringProperty imageUrl = new SimpleStringProperty();
private final StringProperty networkId = new SimpleStringProperty();
public Comment() { }
public Comment(String author, String content, String imageUrl, String networkId) {
this.author.set(author);
this.content.set(content);
this.imageUrl.set(imageUrl);
this.networkId.set(networkId);
}
// getters & setters
}
Gluon CloudLink
This sample uses Gluon CloudLink to store and synchronize data in the cloud. A pair of key and secret tokens, specific for the application, are used for signing the requests that are made to Gluon CloudLink.
To obtain a key and secret, you need a valid subscription to Gluon CloudLink. You can get it here (there is also a 30-day free trial). Sign up and get a valid account on Gluon CloudLink and a link to access the Gluon Dashboard.
Open the Dashboard in your browser, and sign in using the Gluon account credentials provided to create the account.
Go to the App Management link, and you will
find a pair of key/secret tokens. Click on the download button to download the file gluoncloudlink_config.json
, and then store it under your project src/main/resources/
folder.
The content of the file is a JSON object with the key and secret that will grant access to Gluon CloudLink:
{
"gluonCredentials": {
"applicationKey" : "f88XXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
"applicationSecret": "7fbXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
}
}
Login Methods
Out of the box, Gluon Mobile has a sign-in view, so we just need to provide the key and secret tokens for each of the login methods required. Read the User Management section in the Gluon CloudLink documentation for more information about configuring your login methods. It also provides step-by-step guides for each of the supported identity providers.
First of all, go to the User Management link, and select the Login Methods tab. Click on the +
button to add a new login method.
Following the guides mentioned above, we’ll add Twitter, Facebook and/or Google Plus login methods.
Finally, the table will show the different configured login methods. At any moment, those can be edited, to enable or disable them, for instance.
The Service
We build a new DataClient
, which is the access point to the Gluon CloudLink service. Since the application data has
to be only accessible for an authenticated user, we have to provide a UserClient instance and pass it to the DataClientBuilder
.
public class Service {
private DataClient dataClient;
private UserClient userClient;
@PostConstruct
public void postConstruct() {
userClient = new UserClient();
dataClient = DataClientBuilder.create()
.authenticateWith(userClient)
.build();
}
}
We can now add a User
to the service, wrapping it in an ObjectProperty. Bind it to the authenticatedUserProperty()
of the UserClient
, that tracks the user that is currently authenticated. When there is no authenticated user available, the property will hold a null
value.
public class Service {
private final ObjectProperty<User> user = new SimpleObjectProperty<>();
@PostConstruct
public void postConstruct() {
userClient = new UserClient();
dataClient = DataClientBuilder.create()
.authenticateWith(userClient)
.build();
user.bind(userClient.authenticatedUserProperty());
}
public User getUser() {
return user.get();
}
public ObjectProperty<User> userProperty() {
return user;
}
}
We intend to allow a registered user to edit and modify his own comments. For that, we’ll add the activeComment
property to the service.
Once we have a DataClient
reference, we can retrieve data from and store data to Gluon CloudLink.
Using a DataProvider
as an entry point to retrieve a GluonObservableList
, we need to supply a proper
ListDataReader
, a valid entity with the ability to read a list of objects. For that, the DataClient
instance already
has a method: createListDataReader()
. All it needs is an identifier (CLOUD_LIST_ID
), the object class to be read
(Comment.class
), and the synchronization flags:
-
SyncFlag.LIST_READ_THROUGH
andSyncFlag.LIST_WRITE_THROUGH
, so changes in the list of comments are reflected. -
SyncFlag.OBJECT_READ_THROUGH
andSyncFlag.OBJECT_WRITE_THROUGH
, so changes in the properties of any comment inside the list are also reflected.
public class Service {
private final ObjectProperty<Comment> activeComment = new SimpleObjectProperty<>();
public ObjectProperty<Comment> activeCommentProperty() {
return activeComment;
}
public void retrieveComments() {
GluonObservableList<Comment> retrieveList = DataProvider.retrieveList(
dataClient.createListDataReader(CLOUD_LIST_ID,
Comment.class,
SyncFlag.LIST_READ_THROUGH, SyncFlag.LIST_WRITE_THROUGH,
SyncFlag.OBJECT_READ_THROUGH, SyncFlag.OBJECT_WRITE_THROUGH));
...
}
}
When retrieveList
is invoked, if the user is not registered yet, the AuthenticationView
, a built-in view with the registered login methods, will show up.
Before this view can be added, the home view has to be already displayed. By adding an event handler we can track when the comments
view is shown and, only in the first time this occurs, call service.retrieveComments()
and remove the event handler:
public class CommentsPresenter extends GluonPresenter<Comments20> {
public void initialize() {
...
comments.addEventHandler(LifecycleEvent.SHOWN, new EventHandler<LifecycleEvent>() {
@Override
public void handle(LifecycleEvent event) {
comments.removeEventHandler(LifecycleEvent.SHOWN, this);
service.retrieveComments();
}
});
}
}
Now, if we run the application, we’ll be immediately redirected to the AuthenticationView
, and our different login methods will be available.
We can select any of the available login methods, and we’ll access the sign in form of the selected method in an embedded browser.
Once the user has signed in with his social network profile, we can get his name, nick and the public URL of his avatar. We’ll use that to customize the navigation drawer and the comments list cells.
If for any reason the user doesn’t sign in, we need to allow him trying again. For that we’ll use the floating action button:
public class CommentsPresenter extends GluonPresenter<Comments20> {
private static final String SIGN_IN_MESSAGE = "Please, sign in first\n"
+ "to gain online access\n"
+ "to Comments v2.0";
private static final String NO_COMMENTS_MESSAGE = "There are no comments\n"
+ "Click on the Action button\n"
+ "to create one";
public void initialize() {
...
FloatingActionButton floatingActionButton = new FloatingActionButton();
floatingActionButton.textProperty().bind(new When(userProperty().isNotNull())
.then(MaterialDesignIcon.ADD.text)
.otherwise(MaterialDesignIcon.CLOUD_DOWNLOAD.text));
floatingActionButton.setOnAction(e -> {
if (service.getUser() == null) {
service.retrieveComments();
} else {
AppViewManager.EDITION_VIEW.switchView();
}
});
floatingActionButton.showOn(comments);
...
final Label label = new Label(SIGN_IN_MESSAGE);
label.textProperty().bind(new When(userProperty().isNotNull())
.then(NO_COMMENTS_MESSAGE)
.otherwise(SIGN_IN_MESSAGE));
commentsList.setPlaceholder(label);
commentsList.disableProperty().bind(service.userProperty().isNull());
...
}
public ObjectProperty<User> userProperty() {
return service.userProperty();
}
}
And also in this case, we have to disable the Edition
item on the navigation drawer.
We create DrawerManager
to get access to the NavigationDrawer
items and inject
service to get access to its userProperty
. These are used to create the proper binding.
public class AppViewManager {
public static void registerViewsAndDrawer(MobileApplication app) {
...
DrawerManager.buildDrawer(app, header, REGISTRY.getViews());
}
private static class DrawerManager {
static void buildDrawer(MobileApplication app, NavigationDrawer.Header header, Collection<AppView> views) {
final NavigationDrawer drawer = app.getDrawer();
Utils.buildDrawer(drawer, header, views);
final Service service = Injector.instantiateModelOrService(Service.class);
for (Node item : drawer.getItems()) {
if (item instanceof NavigationDrawer.ViewItem &&
((NavigationDrawer.ViewItem) item).getViewName().equals(EDITION_VIEW.getId())) {
item.disableProperty().bind(service.userProperty().isNull());
break;
}
}
service.userProperty().addListener((obs, ov, nv) -> avatar.setImage(getAvatarImage(service)));
avatar.setImage(getAvatarImage(service));
}
...
}
}
Caching images
When dealing with images that are downloaded from the Internet, it is important to use cache strategies that avoid downloading them all over again, specially if we are using them in a ListView.
Thanks to Cache
service in Charm Down, we can easily implement an image cache. Make sure you add the 'cache'
plugin to the build script:
jfxmobile {
downConfig {
version = '3.8.0'
plugins 'display', 'lifecycle', 'statusbar', 'storage', 'cache'
}
...
}
Reload the project and create now a Cache instance in the Service class:
public class Service {
private static final Cache<String, Image> CACHE;
static {
CACHE = Services.get(CacheService.class)
.map(cache -> cache.<String, Image>getCache("images"))
.orElseThrow(() -> new RuntimeException("No CacheService available"));
}
public static Image getUserImage(String userPicture) {
if (userPicture == null || userPicture.isEmpty()) {
userPicture = Service.class.getResource("WikiFont_uniE600_-_userAvatar_-_blue.svg.png").toExternalForm();
}
// try to retrieve image from cache
Image answer = CACHE.get(userPicture);
if (answer == null) {
// if not available yet, create new image from URL
answer = new Image(userPicture, true);
// store it in cache
CACHE.put(userPicture, answer);
}
return answer;
}
...
}
Managing the Avatar
Once the user has signed in, and based on his picture, we can change the avatar’s image in the navigation drawer. Also, we’ll allow different settings for tablets, based on the Display service.
Again we’ll make use of the DrawerManager
inner class:
public class AppViewManager {
private static Avatar avatar;
public static void registerViewsAndDrawer(MobileApplication app) {
...
avatar = new Avatar();
Services.get(DisplayService.class).ifPresent(d -> {
if (d.isTablet()) {
avatar.getStyleClass().add("tablet");
}
});
NavigationDrawer.Header header = new NavigationDrawer.Header("Gluon Mobile",
"The Comments App", avatar);
DrawerManager.buildDrawer(app, header, REGISTRY.getViews());
}
private static class DrawerManager {
public static void buildDrawer(MobileApplication app, Node header, Collection<AppView> views) {
super(app, header, views);
...
service.userProperty().addListener((obs, ov, nv) -> avatar.setImage(getAvatarImage()));
avatar.setImage(getAvatarImage());
}
private static Image getAvatarImage() {
if (service != null && service.userProperty().get() != null) {
return Service.getUserImage(service.userProperty().get().getPicture());
}
return new Image(Comments20.class.getResourceAsStream("/icon.png"));
}
}
}
where we have added style.css
to style the avatar control:
.avatar {
-charm-radius: 22;
}
.avatar.tablet {
-charm-radius: 31;
}
.avatar > .decoration {
-fx-stroke: -primary-swatch-700;
-fx-stroke-width: 1;
}
Edition View
Let’s modify our edition view to include the name and avatar of the user and to take into account the new Comment
model.
On this view, we can either create new comments or edit and modify existing ones. For the latter, this will be allowed only if the user is the author of the comment. Otherwise, we will disable the avatar (by changing the stroke color, with a PseudoClass
), and the comments TextArea
.
When submitting a new comment, we’ll use service.addComment()
, but for editing an existing one, we just replace the content. Thanks to the flags added while retrieving the list of comments from the cloud, the changes will propagate to the cloud, and from there to any existing client, where the listView will be updated properly if it listens to possible changes, as we’ll see below.
public class EditionPresenter extends GluonPresenter<Comments20> {
@FXML
private Avatar avatar;
private boolean editMode;
public void initialize() {
PseudoClass pseudoClassDisable = PseudoClass.getPseudoClass("disabled");
edition.showingProperty().addListener((obs, oldValue, newValue) -> {
if (newValue) {
submit.disableProperty().unbind();
Comment activeComment = service.activeCommentProperty().get();
if (activeComment != null) {
commentsText.setDisable(!activeComment.getNetworkId().equals(service.getUser().getNetworkId()));
avatar.pseudoClassStateChanged(pseudoClassDisable, avatar.setImage(Service.getUserImage(activeComment.getImageUrl()));
authorText.setText(activeComment.getAuthor());
commentsText.setText(activeComment.getContent());
submit.setText("Apply");
submit.disableProperty().bind(Bindings.createBooleanBinding(()->{
return authorText.textProperty()
.isEqualTo(activeComment.getAuthor())
.and(commentsText.textProperty()
.isEqualTo(activeComment.getContent())).get();
}, authorText.textProperty(),commentsText.textProperty()));
editMode = true;
} else {
commentsText.setDisable(false);
avatar.pseudoClassStateChanged(pseudoClassDisable, false);
avatar.setImage(Service.getUserImage(service.getUser().getPicture()));
authorText.setText(service.getUser().getName());
submit.setText("Submit");
submit.disableProperty().bind(Bindings.createBooleanBinding(() -> {
return authorText.textProperty()
.isEmpty()
.or(commentsText.textProperty()
.isEmpty()).get();
}, authorText.textProperty(), commentsText.textProperty()));
editMode = false;
}
authorText.setDisable(!authorText.getText().isEmpty());
...
}
});
Services.get(DisplayService.class)
.ifPresent(d -> {
if (d.isTablet()) {
avatar.getStyleClass().add("tablet");
}
});
avatar.setImage(new Image(service.getUser().getPicture()));
}
@FXML
void onCancel(ActionEvent event) {
authorText.clear();
commentsText.clear();
service.activeCommentProperty().set(null);
getApp().goHome();
}
@FXML
void onSubmit(ActionEvent event) {
Comment comment = editMode ?
service.activeCommentProperty().get() :
new Comment(authorText.getText(), commentsText.getText(),
service.getUser().getPicture(), service.getUser().getNetworkId());
comment.setContent(commentsText.getText());
if (!editMode) {
service.addComment(comment);
}
onCancel(event);
}
}
Finally, we’ll add these style rules to edition.css
:
.avatar {
-charm-radius: 28;
}
.avatar.tablet {
-charm-radius: 35;
}
.avatar > .decoration {
-fx-stroke: -primary-swatch-700;
-fx-stroke-width: 2;
}
.avatar.tablet > .decoration {
-fx-stroke: -primary-swatch-700;
-fx-stroke-width: 3;
}
.avatar:disabled > .decoration {
-fx-stroke: lightgrey;
}
SlidingListTile: Creating a sliding effect
Let’s modify now CommentsListCell
, given that we have a registered user, and we want to allow edit and delete options on each comment.
For that, instead of having two buttons in the right part of the tile, we’ll create a SlidingListTile
: a ListTile
based control that allows sliding with a touch gesture to uncover the buttons beneath it. These buttons are placed in an HBox
control, one to the left, one to the right, and only one of them will be visible when the user slides the tile.
public class SlidingListTile extends StackPane {
private final ListTile tile;
private double iniX = 0d, iniY = 0d;
private final DoubleProperty threshold = new SimpleDoubleProperty(150);
private final BooleanProperty allowed = new SimpleBooleanProperty(true);
private final BooleanProperty scrolling = new SimpleBooleanProperty();
private final BooleanProperty sliding = new SimpleBooleanProperty();
private final BooleanProperty swipedLeft = new SimpleBooleanProperty();
private final BooleanProperty swipedRight = new SimpleBooleanProperty();
private final StringProperty textLeft = new SimpleStringProperty();
private final StringProperty textRight = new SimpleStringProperty();
public SlidingListTile(ListTile tile, boolean allowed, String textLeft, String textRight) {
this.textLeft.set(textLeft);
this.textRight.set(textRight);
HBox backPane = new HBox();
backPane.setAlignment(Pos.CENTER);
backPane.setPadding(new Insets(10));
backPane.getStyleClass().add("sliding");
PseudoClass pseudoClassLeft = PseudoClass.getPseudoClass("left");
tile.translateXProperty().addListener((obs, ov, nv) ->
backPane.pseudoClassStateChanged(pseudoClassLeft, nv.doubleValue() < 0));
Label labelLeft = new Label(this.textLeft.get());
labelLeft.getStyleClass().add("icon-text");
backPane.getChildren().add(labelLeft);
HBox gap = new HBox();
HBox.setHgrow(gap, Priority.ALWAYS);
backPane.getChildren().add(gap);
Label labelRight = new Label(this.textRight.get());
labelRight.getStyleClass().add("icon-text");
backPane.getChildren().add(labelRight);
this.allowed.addListener((obs, ov, nv) -> {
if (nv) {
if (labelLeft.getStyleClass().contains("not-allowed")) {
labelLeft.getStyleClass().remove("not-allowed");
}
if (labelRight.getStyleClass().contains("not-allowed")) {
labelRight.getStyleClass().remove("not-allowed");
}
} else {
if (!labelLeft.getStyleClass().contains("not-allowed")) {
labelLeft.getStyleClass().add("not-allowed");
}
if (!labelRight.getStyleClass().contains("not-allowed")) {
labelRight.getStyleClass().add("not-allowed");
}
}
});
this.allowed.set(allowed);
this.tile = tile;
tile.getStyleClass().add("tile");
tile.setOnMousePressed(e -> {
iniX=e.getSceneX();
iniY=e.getSceneY();
swipedLeft.set(false);
swipedRight.set(false);
});
tile.setOnMouseDragged(e -> {
if (scrolling.get() || (!sliding.get() && Math.abs(e.getSceneY() - iniY) > 10)) {
e.consume();
scrolling.set(true);
}
if (sliding.get() || (!scrolling.get() && Math.abs(e.getSceneX() - iniX) > 10)) {
sliding.set(true);
if (e.getSceneX() - iniX >= -tile.getWidth() + 20 &&
e.getSceneX() - iniX <= tile.getWidth() - 20) {
translateTile(e.getSceneX() - iniX);
}
}
});
tile.setOnMouseReleased(e->{
if (scrolling.get()) {
e.consume();
}
if (sliding.get()) {
if (e.getSceneX() - iniX > this.threshold.get()) {
swipedRight.set(true);
} else if (e.getSceneX() - iniX < -this.threshold.get()) {
swipedLeft.set(true);
} else {
translateTile(0);
}
}
scrolling.set(false);
sliding.set(false);
});
this.getChildren().addAll(backPane, tile);
}
private void translateTile(double posX) {
tile.setTranslateX(posX);
}
public void resetTilePosition() {
TranslateTransition transition = new TranslateTransition(Duration.millis(300), tile);
transition.setInterpolator(Interpolator.EASE_OUT);
transition.setFromX(tile.getTranslateX());
transition.setToX(0);
transition.playFromStart();
}
public BooleanProperty swipedLeftProperty() {
return swipedLeft;
}
public BooleanProperty swipedRightProperty() {
return swipedRight;
}
public BooleanProperty slidingProperty() {
return sliding;
}
public BooleanProperty allowedProperty() {
return allowed;
}
}
While there is no swipe gesture, we create one by adding a listener to mouse drag events, translating the top tile accordingly. After a certain threshold, if the mouse is released the swipe is complete and a boolean property is set to true, otherwise is set to false.
We can style this control adding these rules to comments.css
:
.avatar {
-charm-radius: 22;
}
.avatar.tablet {
-charm-radius: 31;
}
.avatar > .decoration {
-fx-stroke: -primary-swatch-700;
-fx-stroke-width: 1;
}
.sliding {
/* red 400 */
-fx-background-color: #ef5350;
}
.sliding > .label {
-fx-text-fill: white;
}
.sliding > .label.not-allowed {
-fx-text-fill: lightgrey;
}
.sliding:left {
/* light-green 400 */
-fx-background-color: #9ccc65;
}
.list-tile {
-fx-padding: 3.84mm;
}
.list-tile > .text-box {
-fx-font-size: 1.1em;
-fx-spacing: 2mm;
}
.list-cell .tile {
-fx-background-color: white;
}
.list-cell:selected .tile {
-fx-background-color: #f3f3f3;
}
Now, back to our CommentsListCell
, we modify the tile, so it makes use of a SlidingListTile
control. First we create a ListTile
instance, and place an avatar control as the left graphic. Then we define the buttons we’ll place underneath the tile.
Listening to the swipe gestures of the sliding tile (as boolean properties), the cell will accept the consumers that are now passed as arguments to the constructor.
As we mentioned before, we need to listen to possible changes in the comment, coming from the cloud. To do so we’ll add commentChangeListener
to the comment.
public class CommentsListCell extends ListCell<Comment> {
private final SlidingListTile slidingTile;
private final ListTile tile;
private final Avatar avatar;
private final Service service;
private Comment currentItem;
private final ChangeListener<String> commentChangeListener;
public CommentListCell(Service service, Consumer<Comment> consumerLeft, Consumer<Comment> consumerRight) {
this.service = service;
tile = new ListTile();
avatar = new Avatar();
Services.get(DisplayService.class)
.ifPresent(d -> {
if (d.isTablet()) {
avatar.getStyleClass().add("tablet");
}
});
tile.setPrimaryGraphic(avatar);
slidingTile = new SlidingListTile(tile, true, MaterialDesignIcon.DELETE.text, MaterialDesignIcon.EDIT.text);
slidingTile.swipedLeftProperty().addListener((obs, ov, nv) -> {
if (nv && consumerRight != null) {
consumerRight.accept(currentItem);
}
slidingTile.resetTilePosition();
});
slidingTile.swipedRightProperty().addListener((obs, ov, nv) -> {
if (nv && consumerLeft != null) {
consumerLeft.accept(currentItem);
}
slidingTile.resetTilePosition();
});
commentChangeListener = (obs, ov, nv) -> {
Platform.runLater(() -> {
if (currentItem != null) {
tile.textProperty().setAll(currentItem.getAuthor());
tile.textProperty().addAll(getContent(currentItem));
} else {
tile.textProperty().clear();
}
});
};
}
@Override
protected void updateItem(Comment item, boolean empty) {
super.updateItem(item, empty);
updateCurrentItem(item);
if (!empty && item != null) {
avatar.setImage(Service.getUserImage(item.getImageUrl()));
if (service.getUser() != null) {
slidingTile.allowedProperty().set(service.getUser().getNetworkId().equals(item.getNetworkId()));
}
tile.textProperty().setAll(item.getAuthor());
tile.textProperty().addAll(getContent(item));
setGraphic(slidingTile);
setPadding(Insets.EMPTY);
} else {
setGraphic(null);
}
}
private void updateCurrentItem(Comment comment) {
if (currentItem == null || !currentItem.equals(comment)) {
if (currentItem != null) {
currentItem.authorProperty().removeListener(commentChangeListener);
currentItem.contentProperty().removeListener(commentChangeListener);
}
currentItem = comment;
if (currentItem != null) {
currentItem.authorProperty().addListener(commentChangeListener);
currentItem.contentProperty().addListener(commentChangeListener);
}
}
}
private String[] getContent(Comment comment) {
if (comment == null || comment.getContent() == null || comment.getContent().isEmpty()) {
return new String[] {""};
}
final String[] lines = comment.getContent().split("\\n");
if (lines.length > 2) {
lines[1]=lines[1].concat(" ...");
}
return lines;
}
public BooleanProperty slidingProperty() {
return slidingTile.slidingProperty();
}
}
We’ll move the confirmation dialog from the cell factory to CommentsPresenter
. Now when we create the custom cell factory, we define the two consumers we have to pass to the cell, one to edit a comment, one to delete it, including the dialog.
When the user is sliding a tile, we have to disable scrolling the list.
public class CommentsPresenter extends GluonPresenter<Comments20> {
private final BooleanProperty sliding = new SimpleBooleanProperty();
public void initialize() {
...
commentsList.setCellFactory(cell -> {
final CommentListCell commentListCell = new CommentListCell(
service,
// left button: delete comment, only author's comment can delete it
c -> {
if (service.getUser().getNetworkId().equals(c.getNetworkId())) {
showDialog(c);
}
},
// right button: edit comment, everybody can view it, only author can edit it
c -> {
service.activeCommentProperty().set(c);
EDITION_VIEW.switchView();
});
sliding.bind(commentListCell.slidingProperty());
return commentListCell;
});
// block scrolling when sliding
comments.addEventFilter(ScrollEvent.ANY, e -> {
if (sliding.get() && e.getDeltaY() != 0) {
e.consume();
}
});
}
private void showDialog(Comment item) {
...
Optional result = alert.showAndWait();
if(result.isPresent() && result.get().equals(ButtonType.YES)){
commentsList.getItems().remove(item);
}
}
}
Deploy to mobile
Once we have accomplished all the previous steps, it is time to build the application and deploy it on your mobile, to test it and check about performance.
Open the Gradle window, select Tasks→other→androidInstall
or Tasks→other→launchIOSDevice
to deploy on Android or iOS devices.
Conclusion
During this tutorial we have accomplished several tasks:
-
Starting from the default Comments project created by the Gluon plugin, we have modified it to use a
SlidingListTile
control with customCustomListCell
cell to render the created comments. -
We have added cloud persistence with
DataClient
andGluonObservableList
. -
We have learned about enabling User Authentication and login methods.
-
We have seen how customize the
NavigationDrawer
, among other minor features. -
We have also tested the application on desktop and mobile devices.
If you have made it this far, congratulations, we hope this sample and the documentation was helpful to you! In case you have any questions, your first stop should be the Gluon support page, where you can access the latest documentation and the Gluon knowledge base. Gluon also recommends the community to support each other over at the Gluon StackOverflow page. Finally, Gluon offers commercial support as well, to kick start your projects.