Creating QML Controls From Scratch: TimePicker

Creating QML Controls From Scratch: TimePicker

By Chris Cortopassi

TimePicker

Continuing our QML Controls from Scratch series, this time we implement a TimePicker, which allows the user to select a time in terms of hours, minutes and am/pm.

TimePicker's public interface consists of a set() function, a clicked() signal, and an interval property (to specify the granularity of minutes e.g. 1, 2, 5...). A function set() is used instead of a property since TimePicker also returns a time (via clicked()) and we only want to set once (i.e. not a binding).

set() and clicked() both pass a JavaScript Date, but use only the time part (hours, minutes) and don't use the date part (year, month, day). TimePicker is implemented with a Row of three ListViews. The ranges of 1-12 hours and 0-59 minutes are each given five repetitions to provide the illusion that they can be scrolled forever (since ListView doesn't support circular lists).

TimePicker.qml

import QtQuick 2.0

Item {
    id: root
    
// public
    function set(date) { // e.g. new Date(0, 0, 0,  0, 0)) // 12:00 AM
        var hour = date.getHours() + (!date.getHours()? 12: date.getHours() <= 12? 0: -12)//24 hour to AM/PM
        repeater.itemAt(0).positionViewAtIndex(12            * (repetitions - 1) / 2 + hour - 1,                     ListView.Center) // hour
        repeater.itemAt(1).positionViewAtIndex(60 / interval * (repetitions - 1) / 2 + date.getMinutes() / interval, ListView.Center) // minute
        repeater.itemAt(2).positionViewAtIndex((rows - 1) / 2 + (date.getHours() < 12? 0: 1),                        ListView.Center) // am/pm

        for(var column = 0; column < repeater.count; column++)  select(repeater.itemAt(column))
    }

    signal clicked(date date);  //onClicked: print('onClicked', date.toTimeString())
    
    property int interval: 1 // 30 20 15 10 5 2 1 minutes

// private
    width: 500;  height: 200 // default size
    clip: true

    onHeightChanged: resizeTimer.start() // resize
    Timer {id: resizeTimer;  interval: 1000;  onTriggered: set(get())} // ensure same value is selected after resize

    property int rows:        3 // number of rows on the screen     (must be odd). Also change model ''
    property int repetitions: 5 // number of times data is repeated (must be odd)

    Row {
        Repeater {
            id: repeater
            
            model: [ 12 * repetitions, 60 / interval * repetitions, ['', 'AM', 'PM', ''] ] // 1-12 hour, 0-59 minute, am/pm
        
            delegate: ListView { // hours minutes am/pm
                id: view
                
                property int column: index // outer index
                width: root.width / 3;  height: root.height
                snapMode: ListView.SnapToItem
                
                model: modelData
                
                delegate: Item {
                    width: root.width / 3;  height: root.height / rows

                    Text {
                        text: view.get(index)
                        font.pixelSize: Math.min(0.5 * parent.width, parent.height)
                        anchors{verticalCenter:                parent.verticalCenter
                                right:            column == 0? parent.right:            undefined
                                horizontalCenter: column == 1? parent.horizontalCenter: undefined
                                left:             column == 2? parent.left:             undefined
                                rightMargin:             0.2 * parent.width}
                        opacity: view.currentIndex == index? 1: 0.3
                    }
                }
                
                onMovementEnded: {select(view);  timer.restart()}
                onFlickEnded:    {select(view);  timer.restart()}
                Timer {id: timer;  interval: 1;  onTriggered: clicked(root.get())} // emit only once

                function get(index) { // returns e.g. '00' given row
                         if(column == 0)  return index % 12 + 1                            // hour
                    else if(column == 1)  return ('0' + (index * interval) % 60).slice(-2) // minute
                    else                  return model[index]                              // AM/PM
                }
            }
        }
    }
    
    Text { // colon
        text: ':'
        font.pixelSize: Math.min(0.5 * root.width / 3, root.height / rows)
        anchors{verticalCenter: parent.verticalCenter}
        x: root.width / 3 - width / 4
    }
    
    function select(view) {view.currentIndex = view.indexAt(0, view.contentY + 0.5 * view.height)} // index at vertical center

    function get() { // returns e.g. '12:00 AM'
        var hour = repeater.itemAt(0).get(repeater.itemAt(0).currentIndex)         // integer
        var am   = repeater.itemAt(2).get(repeater.itemAt(2).currentIndex) == 'AM' // boolean
        return new Date(0, 0, 0,
                        hour == 12? (am? 0: 12): (am? hour: hour + 12),            // hour
                        repeater.itemAt(1).get(repeater.itemAt(1).currentIndex))   // minute
    }

   // Component.onCompleted: set(new Date(0, 0, 0,  0, 0)) // 12:00 AM otherwise defaults to index 0 selected
}

Test.qml

import QtQuick 2.0 

TimePicker {
    Component.onCompleted: set(new Date(0, 0, 0,  0, 0)) // 12:00 AM
    onClicked:             print('onClicked', Qt.formatTime(date, 'h:mm A'))
}

Summary

In this post, we created a TimePicker control. Use it to allow the user to select a time in terms of hours, minutes and am/pm. In the next installment in the series, we'll create a DatePicker. The source code can be downloaded here.