Статьи

Android SDK: создание вращающейся звонилки

Android SDK предлагает широкий спектр компонентов интерфейса, включая TextViews, Buttons и EditText. Однако добавление пользовательских элементов интерфейса в ваше приложение — отличный способ выделиться на App Market. Из этого туториала вы узнаете, как создать пользовательский интерфейс, научив вас, как создать элегантную поворотную звонилку.

Это руководство предполагает базовые знания Java и Android. Кроме того, я не буду вводить полный API. Вместо этого будут использоваться и объясняться конкретные классы. Я также объясню, шаг за шагом, почему я выбрал конкретную реализацию для этого компонента интерфейса.


У нас есть круг, и мы хотим вращать его вокруг центра. Самый простой подход — взять двумерную декартову систему координат в качестве шаблона.

система координат

Вы касаетесь круга, вращаете его и затем отпускаете. Более того, круг должен регистрировать «бросок», когда это происходит.

Android предоставляет очень простой интерфейс для преобразования изображений с помощью класса Bitmap и класса Matrix. Круг отображается в ImageView. Математическая основа единичного круга может быть реализована с помощью класса Math. Android также предлагает хороший API для распознавания сенсорных событий и жестов. Как видите, большая часть работы уже доступна нам с SDK!


Создайте новый проект Android и добавьте Activity. Затем добавьте рисунок номеронабирателя в папку «drawable». Нам не понадобятся разные версии для разных плотностей отображения, потому что позже мы будем масштабировать изображение программно. Одного изображения с высоким разрешением, которое охватывает все размеры дисплея, должно быть достаточно и оно сэкономит место на телефоне пользователя.

настройка проекта

В этом уроке пользовательский интерфейс будет очень простым. Мы добавляем только один ImageView к макету. Для содержимого ImageView мы выбираем нашу графику номеронабирателя. Это не проблема, чтобы добавить больше видов позже, поскольку это не влияет на нашу вращающуюся звонилку.

01
02
03
04
05
06
07
08
09
10
11
12
13
<?xml version=»1.0″ encoding=»utf-8″?>
<LinearLayout
    xmlns:android=»http://schemas.android.com/apk/res/android»
    android:orientation=»vertical»
    android:layout_width=»fill_parent»
    android:layout_height=»fill_parent»
    android:background=»#FFCCCCCC»>
    <ImageView
        android:src=»@drawable/graphic_ring»
        android:id=»@+id/imageView_ring»
        android:layout_height=»fill_parent»
        android:layout_width=»fill_parent»></ImageView>
</LinearLayout>

Как описано в шагах выше, нам нужно загрузить изображение как растровое изображение при создании действия. Кроме того, нам нужна матрица для преобразований, таких как вращение. Мы делаем все это в методе onCreate.

Нам также нужна правильно масштабированная копия изображения. Поскольку после измерения макета мы знаем только, сколько места занимает наш ImageView, мы добавляем OnGlobalLayoutListener. В методе onGlobalLayout мы можем перехватить событие, когда макет был нарисован, и запросить размер нашего представления.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
private static Bitmap imageOriginal, imageScaled;
private static Matrix matrix;
 
private ImageView dialer;
private int dialerHeight, dialerWidth;
 
@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.main);
     
    // load the image only once
    if (imageOriginal == null) {
        imageOriginal = BitmapFactory.decodeResource(getResources(), R.drawable.graphic_ring);
    }
     
    // initialize the matrix only once
    if (matrix == null) {
        matrix = new Matrix();
    } else {
        // not needed, you can also post the matrix immediately to restore the old state
        matrix.reset();
    }
 
     
    dialer = (ImageView) findViewById(R.id.imageView_ring);
    dialer.setOnTouchListener(new MyOnTouchListener());
    dialer.getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() {
 
        @Override
        public void onGlobalLayout() {
            // method called more than once, but the values only need to be initialized one time
            if (dialerHeight == 0 || dialerWidth == 0) {
                dialerHeight = dialer.getHeight();
                dialerWidth = dialer.getWidth();
                 
                // resize
                Matrix resize = new Matrix();
                resize.postScale((float)Math.min(dialerWidth, dialerHeight) / (float)imageOriginal.getWidth(), (float)Math.min(dialerWidth, dialerHeight) / (float)imageOriginal.getHeight());
                imageScaled = Bitmap.createBitmap(imageOriginal, 0, 0, imageOriginal.getWidth(), imageOriginal.getHeight(), resize, false);
            }
        }
    });
     
}

OnTouchListener хранится очень просто. В событии ACTION_DOWN мы инициализируем угол (т.е. единичный круг). С каждым движением разница между старым и новым углом аддитивно увеличивается до номеронабирателя.

Мы добавляем OnTouchListener как закрытый внутренний класс. Это избавит нас от ненужной передачи необходимых параметров (например, размера представления).

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
 * Simple implementation of an {@link OnTouchListener} for registering the dialer’s touch events.
 */
private class MyOnTouchListener implements OnTouchListener {
     
    private double startAngle;
 
    @Override
    public boolean onTouch(View v, MotionEvent event) {
 
        switch (event.getAction()) {
             
            case MotionEvent.ACTION_DOWN:
                startAngle = getAngle(event.getX(), event.getY());
                break;
                 
            case MotionEvent.ACTION_MOVE:
                double currentAngle = getAngle(event.getX(), event.getY());
                rotateDialer((float) (startAngle — currentAngle));
                startAngle = currentAngle;
                break;
                 
            case MotionEvent.ACTION_UP:
                 
                break;
        }
         
        return true;
    }
     
}

Расчет угла может быть быстро решен с помощью единичного круга. Однако координату события касания сначала необходимо преобразовать в координаты двухмерной декартовой системы координат. Вероятно, это поможет вам самостоятельно настроить эти формулы, чтобы полностью их понять.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
 * @return The angle of the unit circle with the image view’s center
 */
private double getAngle(double xTouch, double yTouch) {
    double x = xTouch — (dialerWidth / 2d);
    double y = dialerHeight — yTouch — (dialerHeight / 2d);
 
    switch (getQuadrant(x, y)) {
        case 1:
            return Math.asin(y / Math.hypot(x, y)) * 180 / Math.PI;
        case 2:
            return 180 — Math.asin(y / Math.hypot(x, y)) * 180 / Math.PI;
        case 3:
            return 180 + (-1 * Math.asin(y / Math.hypot(x, y)) * 180 / Math.PI);
        case 4:
            return 360 + Math.asin(y / Math.hypot(x, y)) * 180 / Math.PI;
        default:
            return 0;
    }
}
 
/**
 * @return The selected quadrant.
 */
private static int getQuadrant(double x, double y) {
    if (x >= 0) {
        return y >= 0 ?
    } else {
        return y >= 0 ?
    }
}

Способ поворота звонилки очень прост. Мы заменяем старый контент ImageView каждый раз новым повернутым растровым изображением.

1
2
3
4
5
6
7
8
9
/**
 * Rotate the dialer.
 *
 * @param degrees The degrees, the dialer should get rotated.
 */
private void rotateDialer(float degrees) {
    matrix.postRotate(degrees);
    dialer.setImageBitmap(Bitmap.createBitmap(imageScaled, 0, 0, imageScaled.getWidth(), imageScaled.getHeight(), matrix, true));
}

Обратите внимание, что кроме некоторых мелких проблем, вращение номеронабирателя уже работает. Также обратите внимание, что если повернутое растровое изображение не помещается в представление, растровое изображение автоматически уменьшается (см. Тип масштаба ImageView). Это вызывает различные размеры.


На вышеприведенном шаге было внесено несколько ошибок, например, изменяющийся масштаб, но их можно легко исправить. Вы видите, где проблема с методом, реализованным выше?

Если вы глубже посмотрите на реализацию вычисления повернутого изображения, вы быстро обнаружите, что каждый раз создается новый экземпляр Bitmap. Это тратит много оперативной памяти и приводит к частой работе сборщика мусора в фоновом режиме (вы заметили эту проблему в выходных данных LogCat). Это заставляет анимацию выглядеть прерывистой.

Эту проблему можно избежать только путем изменения подхода. Вместо того, чтобы вращать растровое изображение и применять его к ImageView, мы должны непосредственно вращать содержимое ImageView. Для этого мы можем изменить тип масштаба ImageView на «Matrix» и каждый раз применять матрицу к ImageView. Это также добавляет еще одно небольшое изменение: теперь нам нужно добавить масштабированное растровое изображение в ImageView сразу после инициализации.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
<?xml version=»1.0″ encoding=»utf-8″?>
<LinearLayout
    xmlns:android=»http://schemas.android.com/apk/res/android»
    android:orientation=»vertical»
    android:layout_width=»fill_parent»
    android:layout_height=»fill_parent»
    android:background=»#FFCCCCCC»>
    <ImageView
        android:src=»@drawable/graphic_ring»
        android:id=»@+id/imageView_ring»
        android:scaleType=»matrix»
        android:layout_height=»fill_parent»
        android:layout_width=»fill_parent»></ImageView>
</LinearLayout>
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
 
            @Override
            public void onGlobalLayout() {
                // method called more than once, but the values only need to be initialized one time
                if (dialerHeight == 0 || dialerWidth == 0) {
                    dialerHeight = dialer.getHeight();
                    dialerWidth = dialer.getWidth();
                     
                    // resize
                    Matrix resize = new Matrix();
                    resize.postScale((float)Math.min(dialerWidth, dialerHeight) / (float)imageOriginal.getWidth(), (float)Math.min(dialerWidth, dialerHeight) / (float)imageOriginal.getHeight());
                    imageScaled = Bitmap.createBitmap(imageOriginal, 0, 0, imageOriginal.getWidth(), imageOriginal.getHeight(), resize, false);
                     
                    dialer.setImageBitmap(imageScaled);
                    dialer.setImageMatrix(matrix);
                }
            }
 
 
    /**
     * Rotate the dialer.
     *
     * @param degrees The degrees, the dialer should get rotated.
     */
    private void rotateDialer(float degrees) {
        matrix.postRotate(degrees);
         
        dialer.setImageMatrix(matrix);
    }

Теперь возникает другая ошибка. Расчет угла работает правильно с центром ImageView. Однако изображение вращается вокруг координаты [0, 0]. Поэтому нам нужно переместить изображение при инициализации в правильное положение. То же самое относится и к вращению. Сначала необходимо сместить растровое изображение, чтобы центр находился в начале ImageView, затем растровое изображение можно повернуть и снова переместить назад.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
            @Override
            public void onGlobalLayout() {
                // method called more than once, but the values only need to be initialized one time
                if (dialerHeight == 0 || dialerWidth == 0) {
                    dialerHeight = dialer.getHeight();
                    dialerWidth = dialer.getWidth();
                     
                    // resize
                    Matrix resize = new Matrix();
                    resize.postScale((float)Math.min(dialerWidth, dialerHeight) / (float)imageOriginal.getWidth(), (float)Math.min(dialerWidth, dialerHeight) / (float)imageOriginal.getHeight());
                    imageScaled = Bitmap.createBitmap(imageOriginal, 0, 0, imageOriginal.getWidth(), imageOriginal.getHeight(), resize, false);
                     
                    // translate to the image view’s center
                    float translateX = dialerWidth / 2 — imageScaled.getWidth() / 2;
                    float translateY = dialerHeight / 2 — imageScaled.getHeight() / 2;
                    matrix.postTranslate(translateX, translateY);
                     
                    dialer.setImageBitmap(imageScaled);
                    dialer.setImageMatrix(matrix);
                }
            }
             
 
    /**
     * Rotate the dialer.
     *
     * @param degrees The degrees, the dialer should get rotated.
     */
    private void rotateDialer(float degrees) {
        matrix.postRotate(degrees, dialerWidth / 2, dialerHeight / 2);
         
        dialer.setImageMatrix(matrix);
    }

Теперь наша анимация работает намного лучше и очень плавно.


Android предоставляет простой в использовании API для распознавания жестов. Например, можно обнаружить прокрутку, долгое нажатие и бросок. Для наших целей мы расширяем класс SimpleOnGestureListener и переопределяем метод onFling. Этот класс рекомендован документацией.

Кроме того, нам нужно оживить вращение и тем самым уменьшить его скорость. Так как это всего лишь небольшие вычисления, нет необходимости передавать функции в дополнительный поток. Вместо этого вы можете передать действие в обработчик, который может манипулировать GUI в основном потоке. Мы даже можем избежать экземпляра обработчика, передав действие в ImageView, который использует для этого свой собственный обработчик.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
 
    private int dialerHeight, dialerWidth;
     
    private GestureDetector detector;
     
    @Override
    public void onCreate(Bundle savedInstanceState) {
 
    
        detector = new GestureDetector(this, new MyGestureDetector());
         
        dialer = (ImageView) findViewById(R.id.imageView_ring);
        dialer.setOnTouchListener(new MyOnTouchListener());
         
 
    /**
     * Simple implementation of an {@link OnTouchListener} for registering the dialer’s touch events.
     */
    private class MyOnTouchListener implements OnTouchListener {
         
        private double startAngle;
 
        @Override
        public boolean onTouch(View v, MotionEvent event) {
 
            switch (event.getAction()) {
                 
                case MotionEvent.ACTION_DOWN:
                    startAngle = getAngle(event.getX(), event.getY());
                    break;
                     
                case MotionEvent.ACTION_MOVE:
                    double currentAngle = getAngle(event.getX(), event.getY());
                    rotateDialer((float) (startAngle — currentAngle));
                    startAngle = currentAngle;
                    break;
                     
                case MotionEvent.ACTION_UP:
                     
                    break;
            }
             
            detector.onTouchEvent(event);
             
            return true;
        }
    }
     
    /**
     * Simple implementation of a {@link SimpleOnGestureListener} for detecting a fling event.
     */
    private class MyGestureDetector extends SimpleOnGestureListener {
        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            dialer.post(new FlingRunnable(velocityX + velocityY));
            return true;
        }
    }
     
    /**
     * A {@link Runnable} for animating the the dialer’s fling.
     */
    private class FlingRunnable implements Runnable {
 
        private float velocity;
 
        public FlingRunnable(float velocity) {
            this.velocity = velocity;
        }
 
        @Override
        public void run() {
            if (Math.abs(velocity) > 5) {
                rotateDialer(velocity / 75);
                velocity /= 1.0666F;
                 
                // post this instance again
                dialer.post(this);
            }
        }
    }

Если вы тщательно протестируете бросок, быстро становится ясно, что в разных положениях номеронабиратель вращается в неправильном направлении. Например, это тот случай, когда начальная и конечная точки находятся только в третьем квадранте. Однако существуют и более сложные случаи ошибок (например, при очистке от квадранта два до четырех над квадрантом три).

Самое простое решение — запомнить затронутые квадранты. Случаи ошибок фиксируются в методе onFling, и скорость соответственно инвертируется.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
 
    private GestureDetector detector;
     
    // needed for detecting the inversed rotations
    private boolean[] quadrantTouched;
     
 
        detector = new GestureDetector(this, new MyGestureDetector());
         
        // there is no 0th quadrant, to keep it simple the first value gets ignored
        quadrantTouched = new boolean[] { false, false, false, false, false };
         
        dialer = (ImageView) findViewById(R.id.imageView_ring);
         
 
    /**
     * Simple implementation of an {@link OnTouchListener} for registering the dialer’s touch events.
     */
    private class MyOnTouchListener implements OnTouchListener {
         
        private double startAngle;
 
        @Override
        public boolean onTouch(View v, MotionEvent event) {
 
            switch (event.getAction()) {
                 
                case MotionEvent.ACTION_DOWN:
                     
                    // reset the touched quadrants
                    for (int i = 0; i < quadrantTouched.length; i++) {
                        quadrantTouched[i] = false;
                    }
                     
                    startAngle = getAngle(event.getX(), event.getY());
                    break;
                     
                case MotionEvent.ACTION_MOVE:
                    double currentAngle = getAngle(event.getX(), event.getY());
                    rotateDialer((float) (startAngle — currentAngle));
                    startAngle = currentAngle;
                    break;
                     
                case MotionEvent.ACTION_UP:
                     
                    break;
            }
             
            // set the touched quadrant to true
            quadrantTouched[getQuadrant(event.getX() — (dialerWidth / 2), dialerHeight — event.getY() — (dialerHeight / 2))] = true;
             
            detector.onTouchEvent(event);
             
            return true;
        }
    }
     
    /**
     * Simple implementation of a {@link SimpleOnGestureListener} for detecting a fling event.
     */
    private class MyGestureDetector extends SimpleOnGestureListener {
        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
             
            // get the quadrant of the start and the end of the fling
            int q1 = getQuadrant(e1.getX() — (dialerWidth / 2), dialerHeight — e1.getY() — (dialerHeight / 2));
            int q2 = getQuadrant(e2.getX() — (dialerWidth / 2), dialerHeight — e2.getY() — (dialerHeight / 2));
 
            // the inversed rotations
            if ((q1 == 2 && q2 == 2 && Math.abs(velocityX) < Math.abs(velocityY))
                    ||
                    ||
                    ||
                    ||
                    ||
                    ||
                    ||
             
                dialer.post(new FlingRunnable(-1 * (velocityX + velocityY)));
            } else {
                // the normal rotation
                dialer.post(new FlingRunnable(velocityX + velocityY));
            }
 
            return true;
        }
    }

Конечно анимация броска должна прекратиться, если вы дотронетесь до номеронабирателя во время работы анимации. Для этого необходимо только добавить логическое значение для проверки, разрешено ли воспроизведение анимации или нет.

В событии ACTION_DOWN вы хотите остановить его. Как только вы отпустите кнопку набора номера (ACTION_UP), анимация должна воспроизводиться.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
 
    // needed for detecting the inversed rotations
    private boolean[] quadrantTouched;
 
    private boolean allowRotating;
     
 
        allowRotating = true;
         
        dialer = (ImageView) findViewById(R.id.imageView_ring);
         
 
            switch (event.getAction()) {
                 
                case MotionEvent.ACTION_DOWN:
                     
                    // reset the touched quadrants
                    for (int i = 0; i < quadrantTouched.length; i++) {
                        quadrantTouched[i] = false;
                    }
                     
                    allowRotating = false;
                     
                    startAngle = getAngle(event.getX(), event.getY());
                    break;
                     
                case MotionEvent.ACTION_MOVE:
                    double currentAngle = getAngle(event.getX(), event.getY());
                    rotateDialer((float) (startAngle — currentAngle));
                    startAngle = currentAngle;
                    break;
                     
                case MotionEvent.ACTION_UP:
                    allowRotating = true;
                    break;
            }
             
 
        @Override
        public void run() {
            if (Math.abs(velocity) > 5 && allowRotating) {
                rotateDialer(velocity / 75);
                velocity /= 1.0666F;
 
                // post this instance again
                dialer.post(this);
            }
        }

Все нужные функции включены в номеронабиратель. Однако это только пример и может быть расширен до бесконечности.

На ум приходит несколько дополнительных улучшений: вы можете отслеживать угол и инвертировать или уменьшать определенные значения (пример этого см. В моем приложении Shutdown Remote). Кроме того, после освобождения номеронабирателя вы можете вернуть его в исходное положение, чтобы повторить механическую функцию настоящих старинных вращающихся циферблатов. Кроме того, номеронабиратель может быть извлечен в пользовательский класс View, что увеличит возможность повторного использования компонента. Возможностей для улучшения предостаточно!


В этом уроке я показал вам, как реализовать простую поворотную звонилку. Конечно, функциональность может быть расширена и улучшена дополнительно, как обсуждалось выше. Android SDK предлагает отличный и очень полезный API со многими классами. Не нужно много изобретать, обычно нужно просто реализовать то, что уже есть. Не стесняйтесь комментировать или оставлять предложения!


http://en.wikipedia.org/wiki/File:Cartesian_coordinates_2D.svg