Keyboard

Creating QML Controls From Scratch: Keyboard

By Chris Cortopassi

Keyboard

In the final installment in our QML Controls from Scratch series, this time we will implement an English-only Keyboard. There are typically three ways to display a virtual keyboard in a QML app:

1. Qt Virtual Keyboard
2. Use the keyboard that ships with the operating system (e.g. on Windows 10 call TabTip.exe in Tablet mode)
3. Roll your own virtual keyboard in QML

If the keyboard must match a designer mockup, 3 is usually the only option, which is the approach we'll take here.

Our Keyboard implementation consists of three QML files:

Keyboard.qml: renders the full-screen keyboard, and reuses our Button control (not used directly by clients)
KeyboardController.qml: non-visual component to show Keyboard.qml and retrieve the text string the user typed in
KeyboardInput.qml: TextInput that brings up Keyboard via KeyboardController

KeyboardController can be used to bring up the Keyboard from anywhere by calling its show() method. Internally, it creates and destroys the Keyboard as necessary. While memory-efficient, the Keyboard can be slow to come up on older systems. If there is enough memory, you can make the Keyboard a singleton instead to always keep it in memory (trading memory usage for speed). KeyboardConroller uses a trick to reparent the Keyboard to the application's rootObject() such that the Keyboard is always on top of everything else.

 

Keyboard.qml

import QtQuick 2.0

Item {
    id: root
    
// public
    property bool password: false

    signal accepted(string text);   // onAccepted: print('onAccepted', text)
    signal rejected();              // onRejected: print('onRejected')
    
// private
    width: 500;  height: 500 // default size

    property double rowSpacing:     0.01 * width  // horizontal spacing between keyboard
    property double columnSpacing:  0.02 * height // vertical   spacing between keyboard
    property bool   shift:          false
    property bool   symbols:        false
    property double columns:        10
    property double rows:           5
              
    MouseArea {anchors.fill: parent} // don't allow touches to pass to MouseAreas underneath
          
    Rectangle { // input
        width: root.width;  height: 0.2 * root.height
        
        Button { // close v
            id: closeButton
            
            text: '\u2193' // BLACK DOWN-POINTING TRIANGLE
            width: height;  height: 0.8 * parent.height
            anchors.verticalCenter: parent.verticalCenter
            x: columnSpacing
            
            onClicked: rejected() // emit
        }
        
        TextInput {
            id: textInput
            
            cursorVisible: true
            anchors {left: closeButton.right;  right: clearButton.left;  verticalCenter: parent.verticalCenter;  margins: 0.03 * root.width}
            font.pixelSize: 0.5 * parent.height
            clip: true
            echoMode: password? TextInput.Password: TextInput.Normal
            
            onAccepted: if(acceptableInput) root.accepted(text) // keyboard Enter key
        }
        
        Button { // clear x
            id: clearButton
            
            text: '\u2715' // BLACK DOWN-POINTING TRIANGLE
            width: height;  height: 0.8 * parent.height
            anchors {verticalCenter: parent.verticalCenter;  right: parent.right;  rightMargin: columnSpacing}
            enabled:   textInput.text
            
            onClicked: textInput.text = ''
        }
    }
    
    Rectangle {
        width: parent.width;  height: 0.8 * parent.height
        anchors.bottom: parent.bottom
               
        Item { // keys
            id: keyboard

            anchors {fill: parent; leftMargin: columnSpacing}
            
            Column {
                spacing: columnSpacing
                
                Row { // 1234567890
                    spacing: rowSpacing

                    Repeater {
                        model: [
                             {text: '1', width: 1},
                             {text: '2', width: 1},
                             {text: '3', width: 1},
                             {text: '4', width: 1},
                             {text: '5', width: 1},
                             {text: '6', width: 1},
                             {text: '7', width: 1},
                             {text: '8', width: 1},
                             {text: '9', width: 1},
                             {text: '0', width: 1},
                        ]
                        
                        delegate: Button {
                            text: modelData.text
                            width: modelData.width * keyboard.width / columns - rowSpacing
                            height: keyboard.height / rows - columnSpacing

                            onClicked: root.clicked(text)
                        }
                    }
                }
                
                Row { // qwertyuiop
                    spacing: rowSpacing

                    Repeater {
                        model: [
                             {text: 'q', symbol: '+',      width: 1},
                             {text: 'w', symbol: '\u00D7', width: 1}, // MULTIPLICATION SIGN
                             {text: 'e', symbol: '\u00F7', width: 1}, // DIVISION SIGN
                             {text: 'r', symbol:      '=', width: 1},
                             {text: 't', symbol:      '/', width: 1},
                             {text: 'y', symbol:      '_', width: 1},
                             {text: 'u', symbol:      '<', width: 1},
                             {text: 'i', symbol:      '>', width: 1},
                             {text: 'o', symbol:      '[', width: 1},
                             {text: 'p', symbol:      ']', width: 1},
                        ]
                        
                        delegate: Button {
                            text: symbols? modelData.symbol: shift? modelData.text.toUpperCase():  modelData.text
                            width: modelData.width * keyboard.width / columns - rowSpacing
                            height: keyboard.height / rows - columnSpacing

                            onClicked: root.clicked(text)
                        }
                    }
                }
                
                Row { // asdfghjkl
                    spacing: rowSpacing

                    Repeater {
                        model: [
                             {text:  '', symbol:  '', width: 0.5},
                             {text: 'a', symbol: '!', width: 1},
                             {text: 's', symbol: '@', width: 1},
                             {text: 'd', symbol: '#', width: 1},
                             {text: 'f', symbol: '$', width: 1},
                             {text: 'g', symbol: '%', width: 1},
                             {text: 'h', symbol: '&', width: 1},
                             {text: 'j', symbol: '*', width: 1},
                             {text: 'k', symbol: '(', width: 1},
                             {text: 'l', symbol: ')', width: 1},
                             {text:  '', symbol:  '', width: 0.5},
                        ]
                        
                        delegate: Button {
                            text: symbols? modelData.symbol: shift? modelData.text.toUpperCase():  modelData.text
                            width: modelData.width * keyboard.width / columns - rowSpacing
                            height: keyboard.height / rows - columnSpacing
                            
                            onClicked: root.clicked(text)
                        }
                    }
                }
                
                Row { // zxcvbnm
                    spacing: rowSpacing

                    Repeater {
                        model: [
                             {text: '\u2191', symbol:       '', width: 1.5}, // UPWARDS ARROW (shift)
                             {text: 'z',      symbol:      '-', width: 1},
                             {text: 'x',      symbol:      "'", width: 1},
                             {text: 'c',      symbol:      '"', width: 1},
                             {text: 'v',      symbol:      ':', width: 1},
                             {text: 'b',      symbol:      ';', width: 1},
                             {text: 'n',      symbol:      ',', width: 1},
                             {text: 'm',      symbol:      '?', width: 1},
                             {text: '\u2190', symbol: '\u2190', width: 1.5}, // LEFTWARDS ARROW (backspace)
                        ]
                        
                        delegate: Button {
                            text: symbols? modelData.symbol: shift? modelData.text.toUpperCase():  modelData.text
                            width: modelData.width * keyboard.width / columns - rowSpacing
                            height: keyboard.height / rows - columnSpacing
                            enabled: text == '\u2190'? textInput.text: true // LEFTWARDS ARROW (backspace)
                            
                            onClicked: root.clicked(text)
                        }
                    }
                }
                
                Row { // space
                    spacing: rowSpacing

                    Repeater {
                        model: [
                             {text: symbols? 'AB': '@#', width: 1.5},
                             {text:                 ',', width: 1},
                             {text:                 ' ', width: 5}, // space
                             {text:                 '.', width: 1},
                             {text:            '\u21B5', width: 1.5}, // DOWNWARDS ARROW WITH CORNER LEFTWARDS (enter)
                        ]
                        
                        delegate: Button {
                            text:    modelData.text
                            width: modelData.width * keyboard.width / columns - rowSpacing
                            height: keyboard.height / rows - columnSpacing
                            enabled: text == '\u21B5'? textInput.text: true // DOWNWARDS ARROW WITH CORNER LEFTWARDS (enter)

                            onClicked: root.clicked(text)
                        }
                    }
                }
            }
        }
    }
    
    signal clicked(string text)
    onClicked: {
        if(     text == '\u2190') { // LEFTWARDS ARROW (backspace)
            var position = textInput.cursorPosition
            textInput.text = textInput.text.substring(0, textInput.cursorPosition - 1) +
                             textInput.text.substring(textInput.cursorPosition, textInput.text.length)
            textInput.cursorPosition = position - 1
        }
        else if(text == '\u2191')  shift   = !shift // UPWARDS ARROW (shift)
        else if(text == '@#' )     symbols = true
        else if(text == 'AB'   )   symbols = false
        else if(text == '\u21B5')  accepted(textInput.text) // DOWNWARDS ARROW WITH CORNER LEFTWARDS (enter)
        else { // insert text
            var position = textInput.cursorPosition
            textInput.text = textInput.text.substring(0, textInput.cursorPosition) + text +
                             textInput.text.substring(textInput.cursorPosition, textInput.text.length)
            textInput.cursorPosition = position + 1
            
            shift = false // momentary
        }
    }
}

KeyboardController.qml

import QtQuick 2.0

Item {
    id: root
    
// public
    property bool password: false

    signal accepted(string text);   // onAccepted: print('onAccepted', text)
    
    function show() {
        keyboard = keyboardComponent.createObject(null,  {password: root.password})
        
        var rootObject = null, object = parent // search up the parent chain to find QQuickView::rootObject()
        while(object) {
            if(object)  rootObject = object
            object = object.parent
        }
        
        keyboard.parent  = rootObject
        keyboard.width   = rootObject.width // resize
        keyboard.height  = rootObject.height
    }

// private
    property Item keyboard: null
    Component {id: keyboardComponent;  Keyboard {}}

    Connections {
        target: keyboard
        
        onAccepted: {
            root.accepted(text) // emit
            keyboard.destroy() // hide
        }
        
        onRejected: keyboard.destroy() // hide
    }
}

KeyboardInput.qml

import QtQuick 2.0

Rectangle { // TextInput from virtual keyboard
    id: root
       
 // public
    property string label:    'label'
    property bool   password: false
    property alias  text:     textInput.text // in/out
    
    signal accepted(string text);   // onAccepted: print('onAccepted', text)

 // private
    width: 500;  height: 100 // default size
    border.width: 0.05 * root.height
    radius:       0.2 * height
    opacity:      enabled  &&  !mouseArea.pressed? 1: 0.3 // disabled/pressed state
    
    Text { // label
        visible: !textInput.text
        text: label
        anchors {left: parent.left;  right: parent.right;  verticalCenter: parent.verticalCenter;  margins: parent.radius}
        font.pixelSize: 0.5 * parent.height    
        opacity: 0.3
    }

    TextInput {
        id: textInput
        
        anchors {left: parent.left;  right: parent.right;  verticalCenter: parent.verticalCenter;  margins: parent.radius}
        font.pixelSize: 0.5 * parent.height
        echoMode: password? TextInput.Password: TextInput.Normal
    }
    
    MouseArea { // comment out to input text via physical keyboard
        id: mouseArea
        
        anchors.fill: parent

        onClicked:  keyboardController.show()
    }
    
    KeyboardController {
        id: keyboardController

        password: root.password

        onAccepted: {
            textInput.text = text
            root.accepted(text) // emit
        }
    }
}

Test.qml

import QtQuick 2.0

KeyboardInput {
    label: 'Username'
    
    onAccepted: print('onAccepted', text)
}

Summary

In this installment, we created a Keyboard. The source code can be downloaded here.