Tutorial: CustomControl

In many cases, you can create custom components by compositing existing QML controls that come with the Cascades framework. Compositing is the combining of visual elements from separate sources into a single image. For more information about custom QML components, see Custom QML components.

If you want to create a control that's unavailable in the Cascades framework and can't be composited by existing components, you can create your own custom control by using  CustomControl.

After you define controls in C++, you can expose them to QML and use them like any other core component. In this tutorial, you learn how to create a circular slider composed of a circular track and a handle that controls the movement of the slider.

You will learn to:

  • Create a custom control in C++
  • Expose the class to QML
  • Use the custom control in QML
Screen showing the circular slider app.

Before you begin

You should have the following things ready:

  • The BlackBerry 10 Native SDK
  • A device or simulator running BlackBerry 10

You can download the tools that you need and learn how to create, build, and run your first Cascades project.

Downloading the full source code

This tutorial uses a step-by-step approach to build the circular slider from scratch. If you want to look at the complete source code for the finished app, you can download the entire project and import it into the Momentics IDE. To learn how, see Import an existing project.

Download the full source code

Set up your project

Create a project

The first thing we do is create a Cascades project using the Standard empty project template. For more information about creating projects, see Managing projects.

Add the image assets

The last part of the setup is adding our image resources to the project. The .zip file contains the following three images.

The unpressed handle image.

handle_active.png

The handle for the slider when it's not pressed

The handle pressed image.

handle_pressed.png

The handle for the slider while it's pressed

The slider track image.

slider_track.png

A circular track that the slider handle sits on

To import the images into your project:

  1. Download the images.zip file.
  2. Extract the images folder to the project's assets folder in your workspace. For example, C:\<your_workspace>\<project_name>\assets.
  3. In the Project Explorer view, refresh your project to display the imported images.
Screen showing the Project Explorer view.

Create the CircularSlider class

The CircularSlider class is where we define our custom control. To create a class:

  1. In your project, right-click the src folder and select New > Class.
  2. Clear the Namespace check box.
  3. In the Class Name field, type a name for your custom control (for example, CircularSlider).
  4. Click Finish.

After you finish, the CircularSlider source file and header file are created in the src folder and opened in the editor.

Create the visuals

Now that the project is set up and we have our source and header files, let's start creating our circular slider. The slider, like any other control, is characterized by two things: visuals and interaction behavior.

The visuals are fairly simple:

  • A root container
  • A circular image as the slider track
  • A container for the slider handle
  • Two handle images (one for each state)

Screen showing the layout of the screen.

In the src folder, double-click CircularSlider.cpp to open the file in the editor. The first thing we do is add the includes and directives at the top of the file.

#include "CircularSlider.h"

#include <bb/cascades/AbsoluteLayout>
#include <bb/cascades/Container>
#include <bb/cascades/DockLayout>
#include <bb/cascades/ImageView>
#include <bb/cascades/TouchEvent>

#define PI 3.141592653589793

using namespace bb::cascades;

In the constructor, we create a root container that uses an AbsoluteLayout and we create the circular image that serves as the slider track.

CircularSlider::CircularSlider(Container *parent)
    : CustomControl(parent)
    , m_revAngle(0)
    , m_angle(0)
    , m_value(0)
{
    // Create a root container with an AbsoluteLayout.
    m_rootContainer = new Container();
    m_rootContainer->setLayout(new AbsoluteLayout());

    // Create the slider track image.
    m_trackImage = ImageView::create()
            .image(QUrl("asset:///images/slider_track.png"));

Next, we create the handle container and assign it a DockLayout. When we create the ImageView for the handle image, we use the mhandleOff image initially, since we want the inactive image displayed when the app starts. To set the initial position of the slider handle, we apply layout properties to the handle image so that it's centered vertically and aligned to the right.

    // Create the handle container and two images, one for
    // active state and one for inactive.
    m_handleContainer = Container::create()
            .layout(new DockLayout());

    // Disable implicit animations for the handle container so that
    // the handle doesn't jump when it's being dragged.
    m_handleImplicitAnimationController = 
            ImplicitAnimationController::create(m_handleContainer)
                .enabled(false);

    // Load the handle images
    m_handleOn = Image(QUrl("asset:///images/handle_pressed.png"));
    m_handleOff = Image(QUrl("asset:///images/handle_inactive.png"));

    // Create the image view for the handle using the image for
    // the inactive handle
    m_handle = ImageView::create()
        .image(m_handleOff)
        .horizontal(HorizontalAlignment::Right)
        .vertical(VerticalAlignment::Center);

Finally, we add everything to the root container and set the root control for the application.

    // Add the handle image to the to handle container
    // and add everything to the root container
    m_handleContainer->add(m_handle);
    m_rootContainer->add(m_trackImage);
    m_rootContainer->add(m_handleContainer);

    // Set the root of the custom control.
    setRoot(m_rootContainer);

Size the control

When you create a custom control, it's important to consider how the control might be reused in other places. It's likely that you will want the ability to resize the control.

To support the relative sizing of the control visuals, we need to set up slots that are invoked when the preferred size of the control is changed. When the slots are invoked, we scale and transform the visuals to reflect the new size. To ensure that no aesthetic loss occurs when the visuals are resized, consider using high-resolution images. In instances where scaling isn't feasible, you might have to resort to creating fixed-size controls.

In the constructor, we connect the  preferredHeightChanged() and preferredWidthChanged() signals to the slots that we use for resizing the control. Then we set the initial size for the control, which emits the preferredHeightChanged() and preferredWidthChanged() signals.

    // Connect the signals of CustomControl to your
    // custom slots to react to size changes
    
    // If any Q_ASSERT statement(s) indicate that the slot failed to connect to 
    // the signal, make sure you know exactly why this has happened. This is not
    // normal, and will cause your app to stop working
    bool connectResult;
    
    // Since the variable is not used in the app, this is added to avoid a 
    // compiler warning
    Q_UNUSED(connectResult);
    
    connectResult = connect(this, 
                            SIGNAL(preferredHeightChanged(float)),
                            this, 
                            SLOT(onHeightChanged(float)));
                            
    // This is only available in Debug builds
    Q_ASSERT(connectResult);
                            
    connectResult = connect(this, 
                            SIGNAL(preferredWidthChanged(float)),
                            this,
                            SLOT(onWidthChanged(float)));
                            
    // This is only available in Debug builds
    Q_ASSERT(connectResult);
    
    // Set the initial size
    m_width = 600;
    m_height = 600;
    setPreferredSize(m_width, m_height);
}

The onHeightChanged() and onWidthChanged() slots update the local height and width of the control. Both functions call onSizeChanged() to resize the control.

// Set the new width of the custom control and
// initiate the resizing
void CircularSlider::onWidthChanged(float width) 
{
    m_width = width;
    onSizeChanged();
}
// Set the new height of the custom control and
// initiate the resizing
void CircularSlider::onHeightChanged(float height) 
{
    m_height = height;
    onSizeChanged();
}

Since the custom control is made up of a number of components, we can't change the size of the root container and expect the control to scale correctly. We must scale and transform all of the children in the control individually. In addition, we must identify the new center of the circle so that we can calculate the x and y coordinates of all the points on the circumference of the circle. We need to know these points on the circumference of the circle when we respond to touch events on the slider.

void CircularSlider::onSizeChanged() 
{
    // Define the center of the circle
    m_centerX = m_width / 2;
    m_centerY = m_height / 2;
    m_radiusCircle = m_width - m_centerX;

    // Set the root container to the new size
    m_rootContainer->setPreferredSize(m_width, m_height);

    // Set the track image to be slightly smaller than the root
    m_trackImage->setPreferredSize(m_width * 0.85, m_height * 0.85);

    // Set the handle image and container to be much smaller
    m_handle->setPreferredSize(0.2 * m_width, 0.2 * m_height);
    m_handleContainer->setPreferredSize(m_width, 0.2 * m_height);

    // Transform the handle container along its y axis to move it
    // into the correct position
    m_handleContainer->setTranslationY((m_height - 0.2 * m_height) / 2);

    // Transform the position of the track image to the correct
    // position
    m_trackImage->setTranslation((m_width - 0.85 * m_width) / 2,
                                 (m_height - 0.85 * m_height) / 2);

    // Clear the circumference points for the circle
    // and reinitialize them to reflect the new size
    m_pointsOnCircumference.clear();

    for (int angle = 0; angle < 360; angle++) {
        const float x = m_centerX + (m_radiusCircle) * cos(angle * M_PI / 180);
        const float y = m_centerY + (m_radiusCircle) * sin(angle * M_PI / 180);
        m_pointsOnCircumference.append(qMakePair(x, y));
    }
}

Handle touch events

The most important part of our custom slider is its ability to handle touch events. A user must be able to press the slider handle to drag it, or press a spot along the circumference of the circle where the slider handle should move to.

In the constructor, we connect the  touch() signal to a custom slot we create for handling touch events. We set the touch listener on the root container because we want to rotate the slider handle even if the user doesn't touch the handle directly.

// Connect to the signal of Container to handle touch events.

// If any Q_ASSERT statement(s) indicate that the slot failed to connect to 
// the signal, make sure you know exactly why this has happened. This is not
// normal, and will cause your app to stop working!!

// Since the variable is not used in the app, this is added to avoid a 
// compiler warning.
Q_UNUSED(connectResult);

connectResult = connect(m_rootContainer, SIGNAL(touch(bb::cascades::TouchEvent*)),
                        this, SLOT(onSliderHandleTouched(bb::cascades::TouchEvent*)));

// This is only available in Debug builds.
Q_ASSERT(connectResult);

The onSliderHandleTouchedSlot() slot accepts a  TouchEvent pointer as a parameter. TouchEvent contains information about the touch event, including the type, which is indicated by a TouchType enumerator. In this application, we respond to three different types of touch events. When events of type isDown or isUp occur, we change the handle image that's being displayed on the slider. When an event of type isMove occurs, we call our function for rotating the slider handle, and pass in the x and y coordinates of where the touch occurred.

Since we're listening for touch events on the root container, the active image for the slider is displayed when you touch a point along the circle, not just the slider handle itself.

void CircularSlider::onSliderHandleTouched(TouchEvent* touchEvent) 
{
    // Change to the active handle image if isDown()
    if (touchEvent->isDown()) {
        m_handle->setImage(m_handleOn);
    // Change to the inactive handle image if isUp()
    } else if (touchEvent->isUp()) {
        m_handle->setImage(m_handleOff);
    // Change the position of the slider handle if isMove()
    } else if (touchEvent->isMove()) {
        processRawCoordinates(touchEvent->localX(), touchEvent->localY());
    }
}

Add the interaction logic

Until now, we've defined the UI for the slider and enabled resizing and touch event handling. All that's left is to add the interaction logic to the slider so that it can respond to touch events.

Specify a valid touch area

Because the slider listens for touch events that occur anywhere on the root container, we must define a valid touch area to improve the interaction of the control.

The valid touch area should be everything except for the middle region of the circle to encourage users to drag their finger along the edge of the slider instead of across the middle of the control. The valid touch area for the slider is the outer 70 percent of the slider track image.

Screen showing the valid touch area of the slider.

Create a function called processRawCoordinates. In addition to specifying the valid touch area, the function identifies the points along the circumference of the circle and determines which point is the closest to the touch event.

void CircularSlider::processRawCoordinates(float touchX, float touchY) {
     
    // Determine the distance from the center to the touch point.
    const float distanceFromCenterToTouchPoint = sqrt(
                        (touchX - m_centerX) * (touchX - m_centerX) +
                        (touchY - m_centerY) * (touchY - m_centerY));

    // Determine whether the touch point is outside the center of
    // the circle and in the valid touch area.
    if (distanceFromCenterToTouchPoint >= (0.3 * m_radiusCircle) &&
        distanceFromCenterToTouchPoint <= m_radiusCircle) {

Find the points along the circumference

After a valid touch occurs we must determine where along the circumference of the slider the user was intending to touch.

Even though we already have the coordinates for the touch, because touch events can occur inside the circle, we must map out those touches to a position along the circumference. We do that by finding the point on the circumference that's the minimum distance away from the touch point.

Screen showing how the least distance is found.
        // The minimum distance from the touch.
        float minDistanceFromTouch = INT_MAX;

        // Measure the distance from the touch to the circumference
        // for each point on the circle and store the X and Y
        // coordinates for the shortest distance.
        for (float i = 0; i < m_pointsOnCircumference.size(); i++) {
            const float x = m_pointsOnCircumference[i].first;
            const float y = m_pointsOnCircumference[i].second;

            const float distanceFromTouch = sqrt((x - touchX) * (x - touchX) +
                                                 (y - touchY) * (y - touchY));

            if (distanceFromTouch < minDistanceFromTouch) {
                minDistanceFromTouch = distanceFromTouch;
                // The angle to rotate the handle container once moved
                m_angle = i;
            }
        }

Now that we know the point along the circumference of the circle, we can rotate the handle container around its pivot point so that the slider handle appears in the correct spot.

By default, every control has a default pivot point at its center. If you must change the pivot point, you can change it using  setPivot(). It's important to note that pivot values are set relative to the center of the control. For this control, we don't have to change the pivot point since the default value fits nicely for our circular slider.

Screen showing how the slider pivots.

After we rotate the handle container, we emit the valueChanged() signal so that the change can be seen in QML.

        // Rotate the handle container along its Z-axis.
        if (m_angle != m_revAngle) {
            m_handleContainer->setRotationZ(m_angle);

            // Our slider has a new value, and we want our QML to know
            m_value = m_angle;
            emit valueChanged(m_value);

            m_revAngle = m_angle;
        }
    }
}

Expose the custom control to QML

In addition to accessing our custom component from QML, we want QML to be notified when a change occurs to the position of the slider.

In CircularSlider.h, in the declaration for the class, we declare the following Q_PROPERTY.

Q_PROPERTY(float value READ value WRITE setValue 
        NOTIFY valueChanged FINAL)

The value property represents the current angle of the slider handle. You can read the value of the property, write to the property, and be notifed of changes to the property by using the valueChanged() signal.

In CircularSlider.cpp, we must then define the value() and setValue() functions.

// Get the value of the slider.
float CircularSlider::value() const
{
    return m_value;
}
 
// Set the value of the slider.
void CircularSlider::setValue(float value) 
{
    if (m_value == value)
        return;

    m_revAngle = m_angle = m_value = value;
    m_handleContainer->setRotationZ(m_angle);

    emit valueChanged(m_value);
}

Now that our custom control is ready, we can expose it to QML using one line of code. In the applicationui.cpp file, before the QML document is created, register the class for QML. We must also include the header file for our CircularSlider.

#include "applicationui.hpp"
#include "CircularSlider.h"

#include <bb/cascades/Application>
#include <bb/cascades/QmlDocument>
#include <bb/cascades/AbstractPane>

using namespace bb::cascades;
 
ApplicationUI::ApplicationUI(bb::cascades::Application *app)
    : QObject(app)
{
    // Register our custom control
    qmlRegisterType<CircularSlider>("custom.lib", 1, 0, "CircularSlider");

    QmlDocument *qml = QmlDocument::create("asset:///main.qml").parent(this);

    // Create root object for the UI
    AbstractPane *root = qml->createRootObject<AbstractPane>();

    // Set created root object as the application scene
    app->setScene(root);
}

After you register the class, you can import its library into QML and use it the way you would any other QML component. In the main.qml file, we capture slider value changes using the onValueChanged signal handler and display the current rotation of the slider handle in a Label.

import bb.cascades 1.0
import custom.lib 1.0
 
Page {
    // The root container
    Container {
        topMargin: 130
        layout: DockLayout {}
        background: Color.create ("#404040")
         
        // Display the current rotation of the slider handle.
        Label {
            text: slider.value 
            horizontalAlignment: HorizontalAlignment.Center
            textStyle {
                base: SystemDefaults.TextStyles.BigText
                fontWeight: FontWeight.Bold
            }            
        }
        // Create the CircularSlider and lay it out
        // just like any other control.
        CircularSlider {
            id: slider   
            horizontalAlignment: HorizontalAlignment.Center
            verticalAlignment: VerticalAlignment.Center
            // Initialize the slider with 180
            value: 180
            // Capture the valueChanged signal and show it as
            // debug output.
            onValueChanged: {
                console.debug(value);
            }
        }    
    }
}

Wrap it up

We're almost finished! If you try to build and run the application now, you'd get a number of errors since the members and variables used in CircularSlider aren't declared in the header file. This is how CircularSlider.h should look when you're finished:

#ifndef CIRCULARSLIDER_H
#define CIRCULARSLIDER_H

#include <bb/cascades/CustomControl>
#include <bb/cascades/Image>
#include <bb/cascades/ImplicitAnimationController>

#include <QPair>
#include <QVector>

namespace bb {
namespace cascades {
class Container;
class ImageView;
class TouchEvent;
}
}
 
class CircularSlider: public bb::cascades::CustomControl
{
    Q_OBJECT
    Q_PROPERTY(float value READ value WRITE setValue
            NOTIFY valueChanged FINAL)
 
public:
    CircularSlider(bb::cascades::Container *parent = 0);

    float value() const;
    void setValue(float value);

Q_SIGNALS:
    void valueChanged(float value);

private Q_SLOTS:
    void onSliderHandleTouched(bb::cascades::TouchEvent *touchEvent);
    void onWidthChanged(float width);
    void onHeightChanged(float height);

private:
    void onSizeChanged();
    void processRawCoordinates(float inX, float inY);

    bb::cascades::Container *m_rootContainer;
    bb::cascades::ImageView *m_trackImage;

    float m_width;
    float m_height;
    float m_revAngle;

    float m_centerX;
    float m_centerY;
    float m_radiusCircle;

    bb::cascades::Image m_handleOn;
    bb::cascades::Image m_handleOff;
    bb::cascades::ImageView *m_handle;
    bb::cascades::Container *m_handleContainer;
    bb::cascades::ImplicitAnimationController m_handleImplicitAnimationController;

    QVector<QPair<float, float> > m_pointsOnCircumference;
    float m_angle;
    float m_value;
};

#endif

Build and run the application, and you're finished. Congratulations, you've created and used your own custom control!

Screen showing the Circular Slider app.

Last modified: 2014-09-30



Got questions about leaving a comment? Get answers from our Disqus FAQ.

comments powered by Disqus