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.

Comments.java
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
}

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.

Dashboard

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.

Configuration File

The content of the file is a JSON object with the key and secret that will grant access to Gluon CloudLink:

src/main/resources/gluoncloudlink_config.json
{
  "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.

Add login method

Following the guides mentioned above, we’ll add Twitter, Facebook and/or Google Plus login methods.

Adding Facebook login method

Finally, the table will show the different configured login methods. At any moment, those can be edited, to enable or disable them, for instance.

Added login method

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.

Service.java
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.

Service.java
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 and SyncFlag.LIST_WRITE_THROUGH, so changes in the list of comments are reflected.

  • SyncFlag.OBJECT_READ_THROUGH and SyncFlag.OBJECT_WRITE_THROUGH, so changes in the properties of any comment inside the list are also reflected.

Service.java
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:

CommentsPresenter.java
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.

Login methods

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.

Login methods

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:

CommentsPresenter.java
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();
    }
}
Allow sign in again

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.

AppViewManager.java
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));
        }

        ...
    }
}
Edition disabled

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:

build.gradle
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:

Service.java
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:

AppViewManager.java
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:

style.css
.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.

new Edition view

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.

EditionPresenter.java
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:

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;
}
adding a comment

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.

SlidingListTile.java
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:

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.

CommentsListCell.java
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.

CommentsPresenter.java
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);
        }
    }
}
slide to delete
slide to edit

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.

Ios Install

Tracking data and users with the Dashboard

At any moment, we can launch the Dashboard web application to track the data changes, in the Data Management view and the users logged in the User Management view:

Dashboard users

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 custom CustomListCell cell to render the created comments.

  • We have added cloud persistence with DataClient and GluonObservableList.

  • 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.