суббота, 4 августа 2012 г.

С ног на голову

Пользовательский интерфейс, предоставляемый приложениями, разработанными для мобильных устройств (планшетов и телефонов) сильно отличается от привычного нам на платформе PC. Это закономерно, поскольку устройства ввода для PC, в стандартной комплектации это мышь (с двумя кнопками и колесиком) и клавиатура, а в случае планшета или телефона, основное устройство ввода - сенсорный экран.
Именно по этой причине, приложения (в основном игры) бездумно портированные с PC платформы, как правило, не успешны. Даже в том случае, если все действия можно выполнять только при помощи мыши, использование клавиатуры, как правило, позволяет оптимизировать их выполнение (хорошей иллюстрацией этому служит StarCraft, играть в который, с использованием только мыши, возможно, но довольно неудобно).
В свою очередь, сенсорный экран планшета имеет довольно мало общего с мышью. Так как у него нет кнопок мыши, портированное приложение не может различать клики левой и правой кнопками мыши (что может быть существенно для приложения), не говоря уже о ставших привычными манипуляциях с колесиком. Использовать Multitouch портированное приложение также не может, поскольку, в случае использования мыши, это понятие лишено всякого смысла. Можно долго говорить о том, как именно следует переносить приложения с PC на Android, но я хотел поговорить не об этом. 
Дело в том, что любое Android-устройство, в наиболее распространенной конфигурации, помимо сенсорного экрана, имеет устройства ввода, совершенно не представимые на PC-платформе, такие как GPS или акселерометр (действительно, было бы очень странно видеть их на стационарном компьютере). Эти устройства ввода нельзя назвать основным источником данных, но у них есть свои ниши применения, в рамках которых они незаменимы. 
В случае акселерометра, такой нишей является разработка разнообразных симуляторов, в которых возможность отслеживания положения устройства в пространстве, становится еще одним средством, позволяющим сделать управление более удобным и интуитивным (разумеется результат использования акселерометра при создании интуитивно-понятных интерфейсов не всегда бывает понятен и даже удобен, но это тема для другого разговора).
Я уже писал ранее о том как можно использовать показания акселерометра.Сегодня я хочу показать, как можно использовать акселерометр в приложении, моделирующем 2-мерную физику (разработанном в предыдущей статье). Начнем с манифеста:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.WhiteRabbit.YoYo"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk android:minSdkVersion="7" />
    <uses-feature android:name="android.hardware.sensor.accelerometer"
              android:required="true" />
   
    <application
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name" >
        <activity
            android:name=".YoYoActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

Тег uses-feature показывает, что для корректной работы приложения, устройство должно быть оборудовано акселерометром. Далее, добавляем код работы с акселерометром в Activity:

package com.WhiteRabbit.YoYo;

import java.util.List;

import android.app.Activity;
import android.content.pm.ActivityInfo;
import android.graphics.Color;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.os.Bundle;
import android.view.ViewGroup;
import android.widget.LinearLayout;

public class YoYoActivity extends Activity implements SensorEventListener {

    private MainView view;
    private Task     task = null;
   
    SensorManager mSensorManager = null;
    Sensor mSensor = null;
   
    public MainView getView() {
        return view;
    }
   
    public void createTask() {
        if (task == null) {
            task = new Task(this);
        }
    }
   
    @Override
    public void onAccuracyChanged(Sensor event, int value) {}

    @Override
    public void onSensorChanged(SensorEvent event) {
        if (view == null) return;
        switch(event.sensor.getType()) {
            case Sensor.TYPE_ACCELEROMETER:
                view.setXYZ(-event.values[1], -event.values[0], event.values[2]);
                break;
        }
    }
   
    @Override
    public void onStart() {
        super.onStart();
        if (mSensor != null) {         
            mSensorManager.registerListener(this, mSensor, 
                SensorManager.SENSOR_DELAY_GAME);
        }
    }
   
    @Override
    protected void onPause() {
        if (task != null) {
            task.terminate();
        }
        super.onPause();
        if (mSensor != null) {
            mSensorManager.unregisterListener(this);
        }
    }
   
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
       
        mSensorManager = (SensorManager)getSystemService(SENSOR_SERVICE);
        if (mSensorManager != null) {
            List<Sensor> sensors = mSensorManager.getSensorList(Sensor.TYPE_ALL);
            if(sensors.size() > 0) {
                for (Sensor sensor : sensors) {
                    switch(sensor.getType()) {
                        case Sensor.TYPE_ACCELEROMETER:
                            if(mSensor == null) mSensor = sensor;
                            break;
                        default:
                            break;
                    }
                }       
            }
        }
       
        LinearLayout.LayoutParams containerParams =
                new LinearLayout.LayoutParams(ViewGroup.LayoutParams.FILL_PARENT,
                        ViewGroup.LayoutParams.WRAP_CONTENT, 0.0F);
        LinearLayout root = new LinearLayout(this);
        root.setOrientation(LinearLayout.VERTICAL);

        root.setBackgroundColor(Color.BLACK);
        root.setLayoutParams(containerParams);

        view = new MainView(this);
        root.addView(view);
        setContentView(root);
        setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
    }
}

Следует обратить внимание, что при вызове метода setXYZ мы меняем местами показания X и Y, полученные от акселерометра, а также меняем им знак. Делается это для того, чтобы привести систему координат, в которой работает акселерометр к системе координат экрана с интерфейсом, зафиксированным в SCREEN_ORIENTATION_PORTRAIT (фактически, мы размернули систему координат на 90 градусов). Теперь внесем необходимые дополнения в MainView:

package com.WhiteRabbit.YoYo;

. . .
public class MainView extends SurfaceView implements SurfaceHolder.Callback {
   
    ...   
    private float sum_x = 0;
    private float sum_y = 0;
    private int   g_cnt = 0;
   
    ...   
    public void setXYZ(float x, float y, float z) {
        if ((Math.abs(z) > Math.abs(x))&&(Math.abs(z) > Math.abs(y))) return;
        sum_x += x;
        sum_y += y;
        g_cnt++;
    }
   
    public Vec2 getGVect(float force) {
        if (g_cnt == 0) return null;
        float x = sum_x / g_cnt;
        float y = sum_y / g_cnt;
        float r = (float)Math.sqrt(x * x + y * y);
        sum_x = 0; sum_y = 0; g_cnt = 0;
        x = (x * force) / r;
        y = (y * force) / r;
        return new Vec2(x, y);
    }
          . . .
}
Большая часть кода остается неизменной.По сравнению с предыдущей версией, добавляются лишь методы setXYZ и getGVect. Здесь также следует обратить внимание на два нюанса, вызванные тем, что мы получаем показания акселерометра с некоторой случайной погрешностью, в результате которой полученные показания постоянно незначительно изменяются, даже в том случае, когда положение устройства остается неизменным. Особенно серьезно этот эффект проявляется в случае расположения устройства параллелно относительно поверхности Земли, когда величины полученных X и Y становятся соизмеримы со значением погрешности.
Чтобы сгладить эти нежелательные эффекты, мы усредняем полученные значения в интервалах между опросами getGVect. Кроме того, мы не обрабатываем показания в случае если абсолютные величины X и Y не превышают абсолютной величины Z (устройство расположено почти горизонтально). В мы getGVect строим результирующий вектор, этот код довольно тривиален. Осталось внести изменения в Task:

package com.WhiteRabbit.YoYo;
. . .
public class Task extends TimerTask {
   
    private final static int G_FORCE   = 10;
    ...

    private void configure() {
        MainView v = ctx.getView();
        float X = v.SZ_X / 2;
        float Y = v.SZ_Y - (v.SZ_Y / 5);
       
        // Создать мир
        Vec2 gravity = new Vec2(0.0f, G_FORCE);
        world = new World(gravity, true);
        BodyDef bd = new BodyDef();
        ground = world.createBody(bd);
        ...       

        // Запустить рассчет
        timer.scheduleAtFixedRate(this, 0, SECONDS_INTERVAL / FRAME_RATE);
    }

          . . .
    @Override
    public void run() {
        // Выполнить расчет итерации
        float timeStep = 2.0f / FRAME_RATE;
        world.step(timeStep, 10, 10);
        // Изменить направление силы гравитации
        Vec2 gravity = ctx.getView().getGVect(G_FORCE);
        if (gravity != null) {
            world.setGravity(gravity);
        }
        // Обновить view
        ctx.getView().update();
    }
}
Значение ускорения свободного падения выносим в костанту, для удобства. Перед выполнением очередного шага рассчета, опрашиваем усредененные показания от акселерометра и изменяем направление вектора действия силы гравитации.
Вот, собственно, и все. После экспорта apk на устройство, убеждаемся, что помимо возможности управления точками подвеса, мы можем управлять положением нити, наклоняя устройство в ту или другую сторону.

P.S. Как обычно, для ленивых, выкладываю весь проект целиком.

Комментариев нет:

Отправить комментарий