вторник, 13 декабря 2011 г.

Исходники

Исходники ко всем статьям перенесены на Google Code. Линки к статьям: 3D Carousel демо:
3D Carousel

ImageView с поддержкой SVG:
SVG ImageView

Остальные статьи объединены в один проект:
Android.Tips

Линки в статьях также обновлены.

вторник, 6 декабря 2011 г.

Использование маски с TextEdit



Source Code
English translation

В продолжение темы о фоорматировании текста при помощи регулярных врыжений мы
разработаем функциональность для наложения маски на EditText.

Конечно, мы можем воспользоваться InputType, но хотелось бы иметь более гибкую функцтональность.


Для начала посмотрим на класс MaskFormatter в Swing.
Он делает все, что нам надо. Изменим кое-что в этом классе.
Оставим внутренние классы как есть. Но MaskFormatter работает
с JFormattedTextField. Необходимо удалить весь функционал, связанный с этим контролом.
В результате, мы имеем что-то типа этого:
public class MaskedFormatter {
 
    // Potential values in mask.
    private static final char DIGIT_KEY = '#';
    private static final char LITERAL_KEY = '\'';
    private static final char UPPERCASE_KEY = 'U';
    private static final char LOWERCASE_KEY = 'L';
    private static final char ALPHA_NUMERIC_KEY = 'A';
    private static final char CHARACTER_KEY = '?';
    private static final char ANYTHING_KEY = '*';
    private static final char HEX_KEY = 'H';
    
    /** The user specified mask. */
    private String mask;    
    
    /** Indicates if the value contains the literal characters. */
    private boolean containsLiteralChars;
    
    private static final MaskCharacter[] EmptyMaskChars = 
           new MaskCharacter[0];

    /** List of valid characters. */
    private String validCharacters;
    
    /** List of invalid characters. */
    private String invalidCharacters;

    /** String used to represent characters not present. */
    private char placeholder;  
    
    /** String used for the passed in value if it does not completely
     * fill the mask. */
    private String placeholderString;    
    
    private transient MaskCharacter[] maskChars;
    
    
    /** Indicates if the value being edited must match the mask. */
    @SuppressWarnings("unused")
 private boolean allowsInvalid;
    
    
    /**
     * Creates a MaskFormatter with no mask.
     */
    public MaskedFormatter() {
        setAllowsInvalid(false);
        containsLiteralChars = true;
        maskChars = EmptyMaskChars;
        placeholder = ' ';
    }

    /**
     * Creates a MaskFormatter with the specified mask.
     * A ParseException
     * will be thrown if mask is an invalid mask.
     *
     * @throws ParseException if mask does not contain valid mask characters
     */
    public MaskedFormatter(String mask) throws ParseException {
        this();
        setMask(mask);
    }    
    
    /**
     * Sets the mask dictating the legal characters.
     * This will throw a ParseException if mask is
     * not valid.
     *
     * @throws ParseException if mask does not contain valid mask characters
     */
    public void setMask(String mask) throws ParseException {
        this.mask = mask;
        updateInternalMask();
    }
    
    /**
     * Returns the formatting mask.
     *
     * @return Mask dictating legal character values.
     */
    public String getMask() {
        return mask;
    }    
    
    /**
     * Updates the internal representation of the mask.
     */
    private void updateInternalMask() throws ParseException {
        String mask = getMask();
        ArrayList<MaskCharacter> fixed = new ArrayList<MaskCharacter>();
        ArrayList<MaskCharacter> temp = fixed;

        if (mask != null) {
            for (int counter = 0, maxCounter = mask.length();
                 counter < maxCounter; counter++) {
                char maskChar = mask.charAt(counter);

                switch (maskChar) {
                case DIGIT_KEY:
                    temp.add(new DigitMaskCharacter());
                    break;
                case LITERAL_KEY:
                    if (++counter < maxCounter) {
                        maskChar = mask.charAt(counter);
                        temp.add(new LiteralCharacter(maskChar));
                    }
                    // else: Could actually throw if else
                    break;
                case UPPERCASE_KEY:
                    temp.add(new UpperCaseCharacter());
                    break;
                case LOWERCASE_KEY:
                    temp.add(new LowerCaseCharacter());
                    break;
                case ALPHA_NUMERIC_KEY:
                    temp.add(new AlphaNumericCharacter());
                    break;
                case CHARACTER_KEY:
                    temp.add(new CharCharacter());
                    break;
                case ANYTHING_KEY:
                    temp.add(new MaskCharacter());
                    break;
                case HEX_KEY:
                    temp.add(new HexCharacter());
                    break;
                default:
                    temp.add(new LiteralCharacter(maskChar));
                    break;
                }
            }
        }
        if (fixed.size() == 0) {
            maskChars = EmptyMaskChars;
        }
        else {
            maskChars = new MaskCharacter[fixed.size()];
            fixed.toArray(maskChars);
        }
    }    
    
    
    /**
     * Sets whether or not the value being edited is allowed to be invalid
     * for a length of time (that is, stringToValue throws
     * a ParseException).
     * It is often convenient to allow the user to temporarily input an
     * invalid value.
     *
     * @param allowsInvalid Used to indicate if the edited value must always
     *        be valid
     */
    public void setAllowsInvalid(boolean allowsInvalid) {
        this.allowsInvalid = allowsInvalid;
    }    


    /**
     * Allows for further restricting of the characters that can be input.
     * Only characters specified in the mask, not in the
     * invalidCharacters, and in
     * validCharacters will be allowed to be input. Passing
     * in null (the default) implies the valid characters are only bound
     * by the mask and the invalid characters.
     *
     * @param validCharacters If non-null, specifies legal characters.
     */
    public void setValidCharacters(String validCharacters) {
        this.validCharacters = validCharacters;
    }

    /**
     * Returns the valid characters that can be input.
     *
     * @return Legal characters
     */
    public String getValidCharacters() {
        return validCharacters;
    }
 
    /**
     * Allows for further restricting of the characters that can be input.
     * Only characters specified in the mask, not in the
     * invalidCharacters, and in
     * validCharacters will be allowed to be input. Passing
     * in null (the default) implies the valid characters are only bound
     * by the mask and the valid characters.
     *
     * @param invalidCharacters If non-null, specifies illegal characters.
     */
    public void setInvalidCharacters(String invalidCharacters) {
        this.invalidCharacters = invalidCharacters;
    }

    /**
     * Returns the characters that are not valid for input.
     *
     * @return illegal characters.
     */
    public String getInvalidCharacters() {
        return invalidCharacters;
    }    
    
    /**
     * If true, the returned value and set value will also contain the literal
     * characters in mask.
     * 
     * For example, if the mask is '(###) ###-####', the
     * current value is '(415) 555-1212', and
     * valueContainsLiteralCharacters is
     * true stringToValue will return
     * '(415) 555-1212'. On the other hand, if
     * valueContainsLiteralCharacters is false,
     * stringToValue will return '4155551212'.
     *
     * @param containsLiteralChars Used to indicate if literal characters in
     *        mask should be returned in stringToValue
     */
    public void setValueContainsLiteralCharacters(
                        boolean containsLiteralChars) {
        this.containsLiteralChars = containsLiteralChars;
    }

    /**
     * Returns true if stringToValue should return literal
     * characters in the mask.
     *
     * @return True if literal characters in mask should be returned in
     *         stringToValue
     */
    public boolean getValueContainsLiteralCharacters() {
        return containsLiteralChars;
    }    
    
    /**
     * Sets the character to use in place of characters that are not present
     * in the value, ie the user must fill them in. The default value is
     * a space.
     * 
     * This is only applicable if the placeholder string has not been
     * specified, or does not completely fill in the mask.
     *
     * @param placeholder Character used when formatting if the value does not
     *        completely fill the mask
     */
    public void setPlaceholderCharacter(char placeholder) {
        this.placeholder = placeholder;
    }

    /**
     * Returns the character to use in place of characters that are not present
     * in the value, ie the user must fill them in.
     *
     * @return Character used when formatting if the value does not
     *        completely fill the mask
     */
    public char getPlaceholderCharacter() {
        return placeholder;
    }
    
    /**
     * Sets the string to use if the value does not completely fill in
     * the mask. A null value implies the placeholder char should be used.
     *
     * @param placeholder String used when formatting if the value does not
     *        completely fill the mask
     */
    public void setPlaceholder(String placeholder) {
        this.placeholderString = placeholder;
    }

    /**
     * Returns the String to use if the value does not completely fill
     * in the mask.
     *
     * @return String used when formatting if the value does not
     *        completely fill the mask
     */
    public String getPlaceholder() {
        return placeholderString;
    }    
    /**
     * Returns a String representation of the Object value
     * based on the mask.  Refer to
     * {@link #setValueContainsLiteralCharacters} for details
     * on how literals are treated.
     *
     * @throws ParseException if there is an error in the conversion
     * @param value Value to convert
     * @see #setValueContainsLiteralCharacters
     * @return String representation of value
     */
    public String valueToString(Object value) throws ParseException {
        String sValue = (value == null) ? "" : value.toString();
        StringBuilder result = new StringBuilder();
        String placeholder = getPlaceholder();
        int[] valueCounter = { 0 };

        append(result, sValue, valueCounter, placeholder, maskChars);
        return result.toString();
    }    
    
    /**
     * Invokes append on the mask characters in
     * mask.
     */
    private void append(StringBuilder result, String value, int[] index,
                        String placeholder, MaskCharacter[] mask)
                          throws ParseException {
        for (int counter = 0, maxCounter = mask.length;
             counter < maxCounter; counter++) {
            mask[counter].append(result, value, index, placeholder);
        }
    }    



И чтобы упростить жизнь создадим класс TextWatcher для работы с форматтером:
 public class MaskedWatcher implements TextWatcher {
 
 private String mMask;
 String mResult = ""; 
 
 public MaskedWatcher(String mask){
  mMask = mask;
 }

 @Override
 public void afterTextChanged(Editable s) {
  
  String mask = mMask;
  String value = s.toString();
  
  if(value.equals(mResult))
   return;

  try {
   
   // prepare the formatter
   MaskedFormatter formatter = new MaskedFormatter(mask);
   formatter.setValueContainsLiteralCharacters(false);
   formatter.setPlaceholderCharacter((char)1);
   
   // get a string with applied mask and placeholder chars
   value = formatter.valueToString(value);
   
   try{
    
    // find first placeholder
    value = value.substring(0, value.indexOf((char)1));

    //process a mask char
    if(value.charAt(value.length()-1) == 
                                      mask.charAt(value.length()-1)){
     value = value.substring(0, value.length() - 1);
    }
    
   }
   catch(Exception e){}
   
   mResult = value;
   
   s.replace(0, s.length(), value);
   
   
  } catch (ParseException e) {
   
   //the entered value does not match a mask
   int offset = e.getErrorOffset();
   value = removeCharAt(value, offset);
   s.replace(0, s.length(), value);
   
  }
  
  
 }

 @Override
 public void beforeTextChanged(CharSequence s, int start, int count,
   int after) {
 }

 @Override
 public void onTextChanged(CharSequence s, int start, 
             int before, int count) {
 }

 public static String removeCharAt(String s, int pos) {

  StringBuffer buffer = new StringBuffer(s.length() - 1);
  buffer.append(s.substring(0, pos)).append(s.substring(pos + 1));
  return buffer.toString();

 } 
 
}


Теперь мы можем работать с масками:
        EditText phone = (EditText)findViewById(R.id.phone);
        phone.addTextChangedListener(
          new MaskedWatcher("(###) ###-##-##")
        )





понедельник, 5 декабря 2011 г.

Форматирование EditText при помощи регулярных выражений

Source Code
English translation

Это будет короткая статья. Посмотрим как форматировать EditText при помощи регулярных выражений.

Класс для форматирования наследуем от Input Filter:

public class PartialRegexInputFilter implements InputFilter {
 
    private Pattern mPattern;
 
    public PartialRegexInputFilter(String pattern){
      mPattern = Pattern.compile(pattern);
    } 

    @Override
    public CharSequence filter(CharSequence source,
            int sourceStart, int sourceEnd,
            Spanned destination, int destinationStart,
            int destinationEnd) 
    {  
        String textToCheck = destination.subSequence(0, destinationStart).
            toString() + source.subSequence(sourceStart, sourceEnd) +
            destination.subSequence(
            destinationEnd, destination.length()).toString();
  
        Matcher matcher = mPattern.matcher(textToCheck);
  
        // Entered text does not match the pattern
        if(!matcher.matches()){
   
            // It does not match partially too
             if(!matcher.hitEnd()){
                 return "";
             }
   
        }
  
        return null;
    }

}


Трюк состоит в том, что если введенный текст не соответствует паттерну, то он может соответствовать ему частично.
Если это так, то мы позволяем ввод текста.

И, как пример, форматирование номера телефона:

final String regex = "\\(\\d{3}\\)\\d{3}\\-\\d{2}\\-\\d{2}";
        
txt.setFilters(
    new InputFilter[] {
        new PartialRegexInputFilter(regex)
    }
);
        
txt.addTextChangedListener(
    new TextWatcher(){

            @Override
            public void afterTextChanged(Editable s) {
                String value  = s.toString();
                if(value.matches(regex))
                    txt.setTextColor(Color.BLACK);
                else
                    txt.setTextColor(Color.RED);
            }

            @Override
            public void beforeTextChanged(CharSequence s, int start,
                int count, int after) {}

            @Override
            public void onTextChanged(CharSequence s, int start,
               int before, int count) {}
           
         }
);
        



В итоге:




среда, 30 ноября 2011 г.

Баг в 3D карусели. Android 3.0+

В классе "CarouselItem" есть метод "getMatrix". Но начиная с API level 11 такой же метод есть в классе View. Необходимо переименовать этот метод во что-нибудь еще в классе "CarouselItem" и в вызове метода "pointToPosition" в классе "CarouselSpinner".

Как будет время я обновлю код.

ARM выпустило бесплатную версию Development Studio

ARM выпустило бесплатную версию Development Studio. Студия работает с существующим NDK, предоставляет низкоуровневый доступ на ARM устройствах — для разработки высокопроизводительных приложений. Соответственно, это обозначает, что приложения могут быть написаны на С или С++...

Круто. Стоит попробовать...

пятница, 18 ноября 2011 г.

Две активности на экране в одно и то же время

Source code
English translation

Возможно вы видели, что некоторые приложения, такие как winamp, Gimp и так далее имеют несколко раздельных окон. Мне было интересно, возможно ли такое же сделать на Android. Конечно, это можно сделать с использованием лэйаутов и прозрачных тем. Но мы не ищем легких путей.

Сначала определим стили:

<style name="Theme.Transparent" parent="android:Theme">
        <item name="android:windowIsTranslucent">true</item>
        <item name="android:windowContentOverlay">@null</item>
        <item name="android:windowBackground">@android:color/transparent</item>
        <item name="android:windowNoTitle">true</item>
        <item name="android:backgroundDimEnabled">false</item>
    </style>

    <style name="Theme.Transparent.Floating">
        <item name="android:windowIsFloating">true</item>
    </style>

"Theme.Transparent" стиль, наподобие того, что бы использован для заставки, но без "windowIsFloating". Благодаря этому первая "activity" заполняет весь экран. Для второй "activity": "Theme.Transparent.Floating". Т.о. вторая не заполнит весь экран и прикосновения к экрану будут доступны первой. Но нет. По умолчанию активности модальны. И прикосновения не будут доступны. Небольшие изменения:

getWindow().setFlags(
       WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
       WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL);
   

Теперь мы видим экран устройства, две активности и первая из них доступна для прикосновений.
Есть еще одна вещь - взаимодействие между ними. При обычном раскладе мы используем
startActivityForResult. Но в нашем случае это непременимо. Простейший путь - использовать ресивер:

mReceiver = new BroadcastReceiver() {

   @Override
   public void onReceive(Context context, Intent intent) {
    
         // The first activity wants to close this one
         String operation = intent.getStringExtra("operation");
         if(operation.equals("hide"))
           finish();
    
         }

   };



В результате:




Возможно для чего-нибудь будет полезно.


четверг, 3 ноября 2011 г.

"Error" диалог в стиле iPhone. Только XML.

Source Code

English translation

В качестве следующего упражнения, я покажу как сделать диалог, похожий на iPhone AlertView.
Мы не будем использовать для этого какие-либо файлы с картинками. Только XML.

В действительности это не сложно. Для начала создадим define xml-drawable для кнопки:


<layer-list xmlns:android="http://schemas.android.com/apk/res/android" >

    <item>
<shape android:shape="rectangle" >

            <corners android:radius="8dip" />

            <gradient
                android:angle="270"
                android:endColor="#FF440000"
                android:startColor="#FF990000"
                android:type="linear" />
        </shape></item>

    <item android:top="20dip">
<shape android:shape="rectangle" >

            <corners
                android:bottomLeftRadius="8dp"
                android:bottomRightRadius="8dp" />

            <solid android:color="#40000000" />
        </shape></item>

</layer-list>


Здесь у нас два слоя. Первый слой – прямоугольник с градиентом. Второй слой – прямоугольник, сдвинутый вверх на 20dip. Этот слой должен перекрывать половину первого слоя. В итоге, реальная кнопка должна быть высотой в 40dip .

Теперь определим содержимое диалога – заголовок, сообщение и кнопка OK:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="center_horizontal|center_vertical"
    android:orientation="vertical" >

    <LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/alert_wrapper"
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:layout_marginLeft="20dip"
        android:layout_marginRight="20dip"
        android:gravity="center_horizontal"
        android:orientation="vertical" >

        <TextView
            android:id="@+id/dialog_title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="5dip"
            android:text="Header container"
            android:textColor="#ffffff"
            android:textSize="17dip"
            android:textStyle="bold" />

        <TextView
            android:id="@+id/dialog_message"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_margin="10dip"
            android:gravity="center_horizontal"
            android:maxLines="5"
            android:scrollbars="vertical"
            android:text="Text container"
            android:textColor="#ffffff"
            android:textSize="15dip" />

        <LinearLayout
            xmlns:android="http://schemas.android.com/apk/res/android"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:layout_marginBottom="10dip"
            android:layout_marginTop="10dip"
            android:gravity="center_horizontal"
            android:orientation="horizontal" >

            <Button
                android:id="@+id/ok"
                android:layout_width="fill_parent"
                android:layout_height="40dip"
                android:layout_marginBottom="10dip"
                android:layout_marginLeft="10dip"
                android:layout_marginRight="10dip"
                android:background="@drawable/iphone_style_button"
                android:text="@string/ok"
                android:textColor="@color/White"
                android:textSize="17dip"
                android:textStyle="bold" />
        </LinearLayout>
    </LinearLayout>

</LinearLayout>


И последнее - фон диалога будет определен в коде. На нужен будет "drawable" с тремя слоями – прямоугольник с белой окантовкой, прямоугольник с основным фоновым цветом и прямоугольник с глянцем.
Для того, чтобы белая окантовка была видна, в первом слое устанавливаем отступы:



// Layers array
  Drawable[] arr = new Drawable[3];

  float roundedCorner[] = new float[] { 8, 8, 8, 8, 8, 8, 8, 8 };

  // First layer - to make a border
  GradientDrawable first = new GradientDrawable();
  first.setShape(GradientDrawable.RECTANGLE);
  first.setCornerRadii(roundedCorner);
  first.setStroke(2, Color.WHITE);

  // Second layer - background
  GradientDrawable second = new GradientDrawable();
  second.setShape(GradientDrawable.RECTANGLE);
  second.setCornerRadii(roundedCorner);
  second.setColor(Color.argb(255, 127, 0, 0));

  // Third layer - for the gloss effect
  GlossDrawable third = new GlossDrawable();

  arr[0] = first;
  arr[1] = second;
  arr[2] = third;

  LayerDrawable background = new LayerDrawable(arr);





Более сложные вещи - в классе GlossDrawable. Перегружаем метод onDraw для расчета эффекта глянца.

Картинка, описывающая расчеты:



По теореме Пифагора находим стороны треугольника:

Далее, используя формулу Герона, находим площадь треугольника:



И радиус описанной окружности:


Теперь рисуем окружность чуть ниже (на 1/8 высоты прямоугольника). Центр окружности:


int centerX = (int) shape.getWidth() / 2;
  int centerY = (int) (-radius + shape.getHeight() / 2);


Прямоугольник для отрисовки окружности:

RectF rectf = new RectF(shape.getWidth() / 2 - radius,  
  shape.getHeight() / 4 - radius * 2, 
  shape.getWidth() / 2 + radius, shape.getHeight() / 4);

И, применяя градиент, получаем:


Используйте эту же технику для создания "info" и "confirm" диалогов. Все, что необходимо сделать - это поменять цвет фона и изменить лэйаут.

Спасибо за внимание.

среда, 2 ноября 2011 г.

Android. 3D карусель



Source Code
English translation

Это моя статья, изначально опубликованная на сайте The Code Project: Codeproject.

Введение

Некоторое время назад я искал как можно сделать 3D карусель на под Андроид. Единственное, что я нашел - это была карусель на UltimateFaves : [1]. Но, как оказалось, она ипользует OpenGL. И без исходников. Хотелось бы сделать ее без использования OpenGL. Казалось, что она не должна быть настолько тяжеловесной, для его использования. Продолжая поиски, я наткнулся на Coverflow Widget : [2]. Это виджет использовал лишь 2D библиотеки. В итоге идея оформилась - использовать класс галереи для карусели. Coverflow Widget просто вращает изображения, а я хотел вращать группу. Ok, по крайней мере, будет использоваться простая тригонометрия. Небольшие сложности возники с классом галереи. Если посмотреть статью о Coverflow Widget : [3], можно увеидеть, что там описаны несколько проблем, таких как: недоступность членов классов AbsSpinner и AdapterView . Я пошел тем же путем, что и там и переписал некоторые классы. Класс Scroller был заменен классом Rotator , который похож на не него Scroller , но вместо сдвига изображений вращает их группу.

Подготовка

Сначала, необходимо определиться с параметрами, которые будут определять поведение карусели. Например, Например минимальное количество элементов в карусели. Она будет выглядеть стрёмно, если в ней будет только 2 элемента, не так ли? Ну, и, чтобы она не сильно грузила девайс определим максимальное количество элементов. Также определим максимальный угол наклона относительно оси Y, набор элементов, текущий элемент, и будет ли отражение. Определим все аттрибуты в файле attrs.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
 <declare-styleable name="Carousel">
  <attr name="android:gravity" /> 
  <attr name="android:animationDuration" />
  <attr name="UseReflection" format="boolean"/>
  <attr name="Items" format="integer"/>
  <attr name="SelectedItem" format="integer"/>
  <attr name="maxTheta" format="float"/>
  <attr name="minQuantity" format="integer"/>
  <attr name="maxQuantity" format="integer"/>
 </declare-styleable> 
</resources>
 

Класс CarouselItem

Дабы упростить жизнь, создадим класс CarouselItem:

public class CarouselItem extends FrameLayout 
 implements Comparable<CarouselItem> {
 
 private ImageView mImage;
 private TextView mText;
 
 private int index;
 private float currentAngle;
 private float x;
 private float y;
 private float z;
 private boolean drawn; 

 // It's needed to find screen coordinates
 private Matrix mMatrix;
 
 public CarouselItem(Context context) {
  
  super(context);
  
  FrameLayout.LayoutParams params = 
    new FrameLayout.LayoutParams(
      LayoutParams.WRAP_CONTENT, 
      LayoutParams.WRAP_CONTENT);
  
  this.setLayoutParams(params);
  
    LayoutInflater inflater = LayoutInflater.from(context);
  View itemTemplate = inflater.inflate(R.layout.item, this, true);
    
  mImage = (ImageView)itemTemplate.findViewById(R.id.item_image);
  mText = (TextView)itemTemplate.findViewById(R.id.item_text);
    
 } 
 
 public String getName(){
  return mText.getText().toString();
 } 
 
 public void setIndex(int index) {
  this.index = index;
 }

 public int getIndex() {
  return index;
 }
 

 public void setCurrentAngle(float currentAngle) {
  
  if(index == 0 && currentAngle > 5){
   Log.d("", "");
  }
  
  this.currentAngle = currentAngle;
 }

 public float getCurrentAngle() {
  return currentAngle;
 }

 public int compareTo(CarouselItem another) {
  return (int)(another.z - this.z);
 }

 …
}
 

В нем добавляем позицию в пространстве, индекс элемента и текущий угол поворота. Также сообразим имплементацию Comparable , она будет полезна для порядка отрисовки элементов.

The Rotator Class

Если посмотреть на исходники класса Scroller class, то можно увидеть два режима: режим скроллинга и режим "броска", в которых вычисляется смещение от исходной точки. Необходимо убрать лишние члены, добавить нужные и заменить вычисления на наши собственные:

public class Rotator {
    private int mMode;
    private float mStartAngle;
    private float mCurrAngle;
    
    private long mStartTime;
    private long mDuration;
    
    private float mDeltaAngle;
    
    private boolean mFinished;

    private float mCoeffVelocity = 0.05f;
    private float mVelocity;
    
    private static final int DEFAULT_DURATION = 250;
    private static final int SCROLL_MODE = 0;
    private static final int FLING_MODE = 1;
    
    private final float mDeceleration = 240.0f;
    
    
    /**
     * Create a Scroller with the specified interpolator. 
     * If the interpolator is null, the default (viscous)
     *  interpolator will be used.
     */
    public Rotator(Context context) {
        mFinished = true;
    }
    
    /**
     * 
     * Returns whether the scroller has finished scrolling.
     * 
     * @return True if the scroller has finished scrolling, 
     * false otherwise.
     */
    public final boolean isFinished() {
        return mFinished;
    }
    
    /**
     * Force the finished field to a particular value.
     *  
     * @param finished The new finished value.
     */
    public final void forceFinished(boolean finished) {
        mFinished = finished;
    }
    
    /**
     * Returns how long the scroll event will take, in milliseconds.
     * 
     * @return The duration of the scroll in milliseconds.
     */
    public final long getDuration() {
        return mDuration;
    }
    
    /**
     * Returns the current X offset in the scroll. 
     * 
     * @return The new X offset as an absolute distance from the origin.
     */
    public final float getCurrAngle() {
        return mCurrAngle;
    }   
    
    /**
     * @hide
     * Returns the current velocity.
     *
     * @return The original velocity less the deceleration. 
     * Result may be negative.
     */
    public float getCurrVelocity() {
        return mCoeffVelocity * mVelocity - mDeceleration 
             * timePassed() /* / 2000.0f*/;
    }

    /**
     * Returns the start X offset in the scroll. 
     * 
     * @return The start X offset as an absolute distance from the origin.
     */
    public final float getStartAngle() {
        return mStartAngle;
    }           
    
    /**
     * Returns the time elapsed since the beginning of the scrolling.
     *
     * @return The elapsed time in milliseconds.
     */
    public int timePassed() {
        return (int)(AnimationUtils.currentAnimationTimeMillis() - 
              mStartTime);
    }
    
    /**
     * Extend the scroll animation. This allows 
     * a running animation to scroll further and longer,
     *  when used with {@link #setFinalX(int)}  
     * or {@link #setFinalY(int)}.
     *
     * @param extend Additional time to scroll in milliseconds.
     * @see #setFinalX(int)
     * @see #setFinalY(int)
     */
    public void extendDuration(int extend) {
        int passed = timePassed();
        mDuration = passed + extend;
        mFinished = false;
    }
    
    /**
     * Stops the animation. Contrary to {@link #forceFinished(boolean)},
     * aborting the animating cause the scroller to 
     * move to the final x and y position
     *
     * @see #forceFinished(boolean)
     */
    public void abortAnimation() {
        mFinished = true;
    }        

    /**
     * Call this when you want to know the new location.  
     * If it returns true, the animation is not yet finished.  
     * loc will be altered to provide the
     * new location.
     */ 
    public boolean computeAngleOffset()
    {
        if (mFinished) {
            return false;
        }
        
        long systemClock = AnimationUtils.currentAnimationTimeMillis();
        long timePassed = systemClock - mStartTime;
        
        if (timePassed < mDuration) {
         switch (mMode) {
          case SCROLL_MODE:

           float sc = (float)timePassed / mDuration;
                     mCurrAngle = mStartAngle + 
                      Math.round(mDeltaAngle * sc);    
                    break;
                    
           case FLING_MODE:

           float timePassedSeconds = timePassed / 1000.0f;
           float distance;

           if(mVelocity < 0)
           {
                     distance = mCoeffVelocity * 
                        mVelocity * timePassedSeconds - 
                         (mDeceleration * timePassedSeconds * 
                           timePassedSeconds / 2.0f);
           }
           else{
                     distance = -mCoeffVelocity * mVelocity * 
                        timePassedSeconds - (mDeceleration * 
                         timePassedSeconds * timePassedSeconds / 2.0f);
           }

                    mCurrAngle = mStartAngle - Math.signum(mVelocity)*
                        Math.round(distance);
                    
                    break;                    
         }
            return true;
        }
        else
        {
         mFinished = true;
         return false;
        }
    }    
    
    /**
     * Start scrolling by providing a starting point 
     * and the distance to travel.
     * 
     * @param startX Starting horizontal scroll 
     *  offset in pixels. Positive numbers will 
     *  scroll the content to the left.
     * @param startY Starting vertical scroll 
     *    offset in pixels. Positive numbers
     *    will scroll the content up.
     * @param dx Horizontal distance to travel. 
     *  Positive numbers will scroll the
     *  content to the left.
     * @param dy Vertical distance to travel. 
     * Positive numbers will scroll the content up.
     * @param duration Duration of the scroll 
     * in milliseconds.
     */
    public void startRotate(float startAngle, float dAngle, int duration) {
        mMode = SCROLL_MODE;
        mFinished = false;
        mDuration = duration;
        mStartTime = AnimationUtils.currentAnimationTimeMillis();
        mStartAngle = startAngle;
        mDeltaAngle = dAngle;
    }    
    
    /**
     * Start scrolling by providing a starting point and the 
     * distance to travel. The scroll will use the default 
     * value of 250 milliseconds for the duration.
     * 
     * @param startX Starting horizontal scroll 
     *  offset in pixels. Positive numbers will 
     *  scroll the content to the left.
     * @param startY Starting vertical scroll 
     *    offset in pixels. Positive numbers
     *    will scroll the content up.
     * @param dx Horizontal distance to travel. 
     *  Positive numbers will scroll the
     *  content to the left.
     * @param dy Vertical distance to travel. 
     * Positive numbers will scroll the content up.
     */
    public void startRotate(float startAngle, float dAngle) {
        startRotate(startAngle, dAngle, DEFAULT_DURATION);
    }
        
    /**
     * Start scrolling based on a fling gesture. 
     * The distance travelled will
     * depend on the initial velocity of the fling.
     * 
     * @param velocityAngle Initial velocity of the fling (X) 
     * measured in pixels per second.
     */
    public void fling(float velocityAngle) {
     
        mMode = FLING_MODE;
        mFinished = false;

        float velocity = velocityAngle;
     
        mVelocity = velocity;
        mDuration = (int)(1000.0f * Math.sqrt(2.0f * mCoeffVelocity * 
          Math.abs(velocity)/mDeceleration));
        
        mStartTime = AnimationUtils.currentAnimationTimeMillis();        
    }
}
 

Отличия CarouselSpinner от AbsSpinner

Во-первых он наследуется от CarouselAdapter , а не от AdapterView. Во-вторых, модифицированный конструктор. В-третьих, измененный метод setSelection(int). Оставляем только вызов метода setSelectionInt . В-четвертых, добавляем геттеры для недоступных членов. Параметры лэйаута - только WRAP_CONTENT. Основные изменения касаются метода pointToPosition . В классе AbsSpinner, он определяет какой элемент был нажат по экранным координатам. Нам, для этого, необходимо спроецировать элементы на экран:

public int pointToPosition(int x, int y) {     

     ArrayList<CarouselItem> fitting = new ArrayList<CarouselItem>();
     
     for(int i = 0; i < mAdapter.getCount(); i++){

      CarouselItem item = (CarouselItem)getChildAt(i);

      Matrix mm = item.getMatrix();
      float[] pts = new float[3];
      
      pts[0] = item.getLeft();
      pts[1] = item.getTop();
      pts[2] = 0;
      
      mm.mapPoints(pts);
      
      int mappedLeft = (int)pts[0];
      int mappedTop =  (int)pts[1];
            
      pts[0] = item.getRight();
      pts[1] = item.getBottom();
      pts[2] = 0;
      
      mm.mapPoints(pts);

      int mappedRight = (int)pts[0];
      int mappedBottom = (int)pts[1];
      
      if(mappedLeft < x && mappedRight > x & 
            mappedTop < y && mappedBottom > y)
       fitting.add(item);
      
     }
     
     Collections.sort(fitting);
     
     if(fitting.size() != 0)
      return fitting.get(0).getIndex();
     else
      return mSelectedPosition;
    }
 

CarouselAdapter vs. AdapterView

Единственные изменения - в методе updateEmptyStatus недоступные члены заменены геттерами.

Класс Carousel

Класс FlingRunnable заменен классом FlingRotateRunnable , который похож на FlingRunnable , но имеет дело с углами, а не с x-координатой:

private class FlingRotateRunnable implements Runnable {

        /**
         * Tracks the decay of a fling rotation
         */  
  private Rotator mRotator;

  /**
         * Angle value reported by mRotator on the previous fling
         */
        private float mLastFlingAngle;
        
        /**
         * Constructor
         */
        public FlingRotateRunnable(){
         mRotator = new Rotator(getContext());
        }
        
        private void startCommon() {
            // Remove any pending flings
            removeCallbacks(this);
        }
        
        public void startUsingVelocity(float initialVelocity) {
            if (initialVelocity == 0) return;
            
            startCommon();
                        
            mLastFlingAngle = 0.0f;
            
            mRotator.fling(initialVelocity);
                        
            post(this);
        }               
        
        public void startUsingDistance(float deltaAngle) {
            if (deltaAngle == 0) return;
            
            startCommon();
            
            mLastFlingAngle = 0;
            synchronized(this)
            {
             mRotator.startRotate(0.0f, -deltaAngle, mAnimationDuration);
            }
            post(this);
        }
        
        public void stop(boolean scrollIntoSlots) {
            removeCallbacks(this);
            endFling(scrollIntoSlots);
        }        
        
        private void endFling(boolean scrollIntoSlots) {
            /*
             * Force the scroller's status to finished 
               (without setting its position to the end)
             */
         synchronized(this){
          mRotator.forceFinished(true);
         }
            
            if (scrollIntoSlots) scrollIntoSlots();
        }
                  
  public void run() {
            if (Carousel.this.getChildCount() == 0) {
                endFling(true);
                return;
            }   
            
            mShouldStopFling = false;
            
            final Rotator rotator;
            final float angle;
            boolean more;
            synchronized(this){
             rotator = mRotator;
             more = rotator.computeAngleOffset();
             angle = rotator.getCurrAngle();             
            }            
         
            // Flip sign to convert finger direction to 
            // list items direction (e.g. finger moving down 
            // means list is moving towards the top)
            float delta = mLastFlingAngle - angle;                        
            
            //////// Should be reworked
            trackMotionScroll(delta);
            
            if (more && !mShouldStopFling) {
                mLastFlingAngle = angle;
                post(this);
            } else {
                mLastFlingAngle = 0.0f;
                endFling(true);
            }              
 }  
}
 
Также я добавил класс ImageAdapter , который дает возможность делать отражения элементов, как в Coverflow Widget. Также несколько вспомогательных приватных переменных. Конструктор получает список картинок и создает на их основе ImageAdapter . Самое важное в конструкторе - установить поддержку static трансформаций. И разместить картинки по своим местам:

/**
  * Setting up images
  */
 void layout(int delta, boolean animate){
          
        if (mDataChanged) {
            handleDataChanged();
        }
        
        // Handle an empty gallery by removing all views.
        if (this.getCount() == 0) {
            resetList();
            return;
        }
        
        // Update to the new selected position.
        if (mNextSelectedPosition >= 0) {
            setSelectedPositionInt(mNextSelectedPosition);
        }        
        
        // All views go in recycler while we are in layout
        recycleAllViews();        
        
        // Clear out old views
        detachAllViewsFromParent();
        
        
        int count = getAdapter().getCount();
        float angleUnit = 360.0f / count;

        float angleOffset = mSelectedPosition * angleUnit;
        for(int i = 0; i< getAdapter().getCount(); i++){
         float angle = angleUnit * i - angleOffset;
         if(angle < 0.0f)
          angle = 360.0f + angle;
            makeAndAddView(i, angle);         
        }

        // Flush any cached views that did not get reused above
        mRecycler.clear();

        invalidate();

        setNextSelectedPositionInt(mSelectedPosition);
        
        checkSelectionChanged();
        
        ////////mDataChanged = false;
        mNeedSync = false;
        
        updateSelectedItemMetadata();
        }
 

Далее методы установки элементов. Высоту картинок устанавливаем в 1/3 высоты родительского окна(чтобы влезали при наклоне). Потом надо будет придумать
что-нибудь более оригинальное.

private void makeAndAddView(int position, float angleOffset) {
        CarouselItem child;
  
        if (!mDataChanged) {
            child = (CarouselItem)mRecycler.get(position);
            if (child != null) {

                // Position the view
                setUpChild(child, child.getIndex(), angleOffset);
            }
            else
            {
                // Nothing found in the recycler -- 
                // ask the adapter for a view
                child = (CarouselItem)mAdapter.
                        getView(position, null, this);

                // Position the view
                setUpChild(child, 
                    child.getIndex(), angleOffset);             
            }
            return;
        }

        // Nothing found in the recycler -- 
        // ask the adapter for a view
        child = (CarouselItem)mAdapter.
                   getView(position, null, this);

        // Position the view
        setUpChild(child, 
                child.getIndex(), angleOffset);

    }      
      

    private void setUpChild(CarouselItem child, 
             int index, float angleOffset) {
                
     // Ignore any layout parameters for child, 
     // use wrap content
        addViewInLayout(child, -1 /*index*/, 
            generateDefaultLayoutParams());

        child.setSelected(index == mSelectedPosition);
        
        int h;
        int w;
        int d;
        
        if(mInLayout)
        {
            w = child.getMeasuredWidth();
            h = child.getMeasuredHeight();
            d = getMeasuredWidth();
         
        }
        else
        {
            w = child.getMeasuredWidth();
            h = child.getMeasuredHeight();
            d = getWidth();
         
        }
        
        child.setCurrentAngle(angleOffset);
        
        // Measure child
        child.measure(w, h);
        
        int childLeft;
        
        // Position vertically based on gravity setting
        int childTop = calculateTop(child, true);
        
        childLeft = 0;

        child.layout(childLeft, childTop, w, h);
        
        Calculate3DPosition(child, d, angleOffset);
        
    } 


Посмотрим на метод trackMotionScroll в классе Gallery . Он вызывается в режиме скроллинга и "броска" и выполняет всю необходимую анимацию. Но, двигает элементы только вдоль х-координаты. But it moves images just by x-coordinate. Для вращения в пространстве, его надо переработать. Просто меняем текущий угол и вычисляем позицию в пространстве:

void trackMotionScroll(float deltaAngle) {
    
        if (getChildCount() == 0) {
            return;
        }
                
        for(int i = 0; i < getAdapter().getCount(); i++){
         CarouselItem child = (CarouselItem)getAdapter().
                      getView(i, null, null);
         float angle = child.getCurrentAngle();
         angle += deltaAngle;
         while(angle > 360.0f)
          angle -= 360.0f;
         while(angle < 0.0f)
          angle += 360.0f;
         child.setCurrentAngle(angle);
            Calculate3DPosition(child, getWidth(), angle);         
        }
        
        // Clear unused views
        mRecycler.clear();        
        
        invalidate();
    }  
 
Также, по окончании анимации, необходимо расставить элементы по своим местам:
/**
     * Brings an item with nearest to 0 degrees angle to 
     * this angle and sets it selected 
     */
    private void scrollIntoSlots(){
     
     // Nothing to do
        if (getChildCount() == 0 || mSelectedChild == null) return;
        
        // get nearest item to the 0 degrees angle
        // Sort itmes and get nearest angle
     float angle; 
     int position;
     
     ArrayList<CarouselItem> arr = new ArrayList<CarouselItem>();
     
        for(int i = 0; i < getAdapter().getCount(); i++)
         arr.add(((CarouselItem)getAdapter().getView(i, null, null)));
        
        Collections.sort(arr, new Comparator<CarouselItem>(){
           @Override
             public int compare(CarouselItem c1, CarouselItem c2) {
            int a1 = (int)c1.getCurrentAngle();
            if(a1 > 180)
             a1 = 360 - a1;
            int a2 = (int)c2.getCurrentAngle();
            if(a2 > 180)
             a2 = 360 - a2;
            return (a1 - a2) ;
           }
         
        });
        
        
        angle = arr.get(0).getCurrentAngle();
                
        // Make it minimum to rotate
     if(angle > 180.0f)
      angle = -(360.0f - angle);
     
        // Start rotation if needed
        if(angle != 0.0f)
        {
         mFlingRunnable.startUsingDistance(-angle);
        }
        else
        {
            // Set selected position
            position = arr.get(0).getIndex();
            setSelectedPositionInt(position);
         onFinishedMovement();
        }

        
    }
 
Вращение к определенному элементу:
void scrollToChild(int i){  
  
  CarouselItem view = (CarouselItem)getAdapter().
                  getView(i, null, null);
  float angle = view.getCurrentAngle();
  
  if(angle == 0)
   return;
  
  if(angle > 180.0f)
   angle = 360.0f - angle;
  else
   angle = -angle;

             mFlingRunnable.startUsingDistance(angle);

  
 }
 
Код метода Calculate3DPosition :
private void Calculate3DPosition(CarouselItem child, int diameter, 
        float angleOffset){
     
     angleOffset = angleOffset * (float)(Math.PI/180.0f);     
     
     float x = - (float)(diameter/2  * Math.sin(angleOffset)) + 
              diameter/2 - child.getWidth()/2;
     float z = diameter/2 * (1.0f - (float)Math.cos(angleOffset));
     float y = - getHeight()/2 + (float) (z * Math.sin(mTheta));
     
     child.setX(x);
     child.setZ(z);
     child.setY(y);
     
    }

Удаляем некоторые методы, которые не имеют смысла в нашем случае: offsetChildrenLeftAndRight, detachOffScreenChildren, setSelectionToCenterChild, fillToGalleryLeft, fillToGalleryRight. Самая важная часть находится в методе getChildStaticTransformation . В нем выполняется размещение объектов в пространстве. Он просто берет готовую позицию из элемента, которая была рассчитана в методе Calculate3DPosition:
protected boolean getChildStaticTransformation
 (View child, Transformation transformation) {

 transformation.clear();
 transformation.setTransformationType(Transformation.TYPE_MATRIX);
  
 // Center of the item
 float centerX = (float)child.getWidth()/2, 
    centerY = (float)child.getHeight()/2;
  
 // Save camera
 mCamera.save();
  
 // Translate the item to it's coordinates
 final Matrix matrix = transformation.getMatrix();
 mCamera.translate(((CarouselImageView)child).getX(), 
    ((CarouselImageView)child).getY(), 
    ((CarouselImageView)child).getZ());
  
 // Align the item
 mCamera.getMatrix(matrix);
 matrix.preTranslate(-centerX, -centerY);
 matrix.postTranslate(centerX, centerY);
  
 // Restore camera
 mCamera.restore();  
  
 return true;
}    
 
Также, необходимо учитывать, что, если просто вращать элементы, они могут перекрыть друг друга.
Например элемент с z-координатой 100.0 может быть отрисован перед элементом с координатой 50.0.
Чтобы избежать этого, надо переопределить метод getChildDrawingOrder:
protected int getChildDrawingOrder(int childCount, int i) {

     // Sort Carousel items by z coordinate in reverse order
     ArrayList<CarouselItem> sl = new ArrayList<CarouselItem>();
     for(int j = 0; j < childCount; j++)
     {
      CarouselItem view = (CarouselItem)getAdapter().getView(j,null, null);
      if(i == 0)
       view.setDrawn(false);
      sl.add((CarouselItem)getAdapter().getView(j,null, null));
     }

     Collections.sort(sl);
     
     // Get first undrawn item in array and get result index
     int idx = 0;
     
     for(CarouselItem civ : sl)
     {
      if(!civ.isDrawn())
      {
       civ.setDrawn(true);
       idx = civ.getIndex();
       break;
      }
     }
     
     return idx;

    }
 
Ok, здесь еще много надо переработать, отловить баги и оптимизировать, но в первом приближении это работает. Иконки позаимствованы здесь: [4].

Ресурсы

  1. http://ultimatefaves.com/
  2. http://www.inter-fuser.com/2010/02/android-coverflow-widget-v2.html
  3. http://www.inter-fuser.com/2010/01/android-coverflow-widget.html
  4. http://www.iconsmaster.com/Plush-Icons-Set/

четверг, 27 октября 2011 г.

Android. ImageView с поддержкой SVG


English translation

Source Code

Эта моя стать изначально была опубликована на сайте The Code Project. Оригинал Codeproject.

Введение

Как вы знаете, Андроид не поддерживает формат SVG. Однако у него есть масса преимуществ. Во первых - масштабируемость. Отпадает необходимость держать картинки в разных разрешениях. Нет необходимости, к примеру, масштабировать битмэп с потерей качества. SVG может быть отмасштабирована в любое разрешение и качество картинки будет тем же. Во-вторых, SVG - это обычный xml-файл, что в итоге размер файла намного меньше, чем та же картинка в растровом формате. Более того, картинку сожно изменять "на лету". SVG файл можно открыть в обычном текстовом редакторе и помотреть, как картинка формируется. И так далее... Но, так как, к сожалению, Android не поддерживает SVG, то придется немного повозиться с NDK. Хорошая новоость заключается в том, что возни будет немного. У нас имеются готовые библиотеки с открытым исходным кодом для растеризации SVG.

В интернете достаточно уроков о том, как работать с NDK. Не будем повторяться. Единственно, немного полезных замечаний:

  • Во-первых, необходим эклипс. Загрузить можно здесь: [1].
  • Или, как альтернативу, можно использовать [2]. Раньше я предпочитал ее, но в последнее время она без бубна работает не очень...
  • После установки эклипса, в нем нужно установить CDT плагин.
  • ADT плагин [3].
  • После этого, добавляем Eclipse Sequoyah[4] плагин, чтобы дебажить c/c++ код.
  • Для пользователей Windows необходимо установить cygwin[5] (Добавляем Cygwin/bin в PATH. В эклипсе - cinfigure build path, и устанавливаем build command в что-то типа "bash C:\AndroidNDK\ndk-build").
  • Android SDK [6].
  • Android NDK. Используем CrystaX NDK[7]. (В текущей версии NDK (6) от Google Android NDK libsvg собрать не удалось)
  • В настройках эклипса указываем пути к NDK и SDKIn
 Сначала используем библиотеку android-libsvg [8]. Кроме того, она зависит от libsvg, libpng, libjpeg, libexpat и zlib. Ее преимущество в том, что она поддерживает почти все фичи SVG. Создаем папку android-libsvg где-нибудь в файловой системе и в консоли (или cygwin) выполняем “bzr branch lp:libsvg-android”. Bazaar загрузит исходники.

Ok. Теперь создаем новый проект “ImageViewSvg”. В контекстном меню проекта выбираем AndroidTools/Add Native support. К проекту добавится папка “jni”. Удаляем из нее все и копируем содержимое папки “jni” из android-libsvg . Обновляем папку jni нашего проекта(F5). Посмотрим на файл Android.mk в папке “jni”. Что означают некоторые переменные:


  • LOCAL_PATH := $(call my-dir) – my-dyr макрос устанавливает LOCAL_PATH переменную, которая указывает где находятся файлы исходников(в текущем каталоге)
  • include $(CLEAR_VARS) – Очищает вс локальные переменные
  • LOCAL_MODULE – Имя библиотеки
  • LOCAL_CFLAGS – Устанавливает флаги компилятора пути к "include" файлам
  • LIBJPEG_SOURCES, … - Список всех файлов исходников, для каждой библиотеки, которая будет использоваться
  • LOCAL_LDLIBS – Линки к дополнительным библиотекам
  • LOCAL_SRC_FILES – Список всех исходников для всех библиотек
  • BUILD_SHARED_LIBRARY – Линк к mk файлу, который построит "shared" библиотку 


Более подробно смотрите файлANDROID-MK.TXT в NDK.
Иногда в WIndows, после рестарта эклипса, он не может запустить ndk-build. Необходимо переустановить "build command" в крнфигурации.

Далее создаем com.toolkits.libsvgandroid и копируем туда SvgRaster.java из проекта libsvg-android . Все практически готово.


Для поддержки классом ImageView SVG формата, необходимо перегрузить в нем некоторые методы. Но еще хотелось бы использовать стандартный атрибут android:src как SVG файл и устанавливать его из стандартной папки“drawable” вместо папки “raw”. Вначале изменим конструктор. Для доступа к android:src атрибуту, добавляем attrs.xml в res/values:

<?xml version="1.0" encoding="utf-8"?>
<resources>
 <declare-styleable name="ImageViewSvg">
  <attr name="android:src"/>
 </declare-styleable>
</resources>
 
Посмотрим на исходники конструктора класса ImageView . Имеем следующий код:

Drawable d = 
   a.getDrawable(com.android.internal.R.styleable.ImageView_src);
if (d != null) {
    setImageDrawable(d);
}
 



Далее посмотрим на метод setImageBitmap . Он просто вызывает setImageDrawable. Т.о. мы можем использовать его, если у нас будет подходящий битмэп. Правда еще необходимо достать файл из “drawable”. Ну, здесь - как два пальца об асфальт - – достаем ID ресурса из android:src атрибута и читаем его в поток. Далее, при помощи, libandroidsvg парсим SVG:

В итоге конструктор выглядит так:

public ImageViewSvg(Context context, AttributeSet attrs, int defStyle) {

  // Let's try load supported by ImageView formats
  super(context, attrs, defStyle);
        
        if(this.getDrawable() == null)
        {
         //  Get defined attributes
            TypedArray a = context.obtainStyledAttributes(attrs,
                    R.styleable.ImageViewSvg, defStyle, 0);
                        
            // Getting a file name
            CharSequence cs = 
                a.getText(R.styleable.ImageViewSvg_android_src);
            String file = cs.toString();
            
            // Is it SVG file?
            if (file.endsWith(".svg")) {
             
             // Retrieve ID of the resource
                int id = 
                    a.getResourceId(
                    R.styleable.ImageViewSvg_android_src, -1);
                if(id != -1){
                try {
                  // Get the input stream for the raw resource
                  InputStream inStream = 
                                     getResources().openRawResource(id);
                  int size = inStream.available();
                  
                  // Read into the buffer
                  byte[] buffer = new byte[size];
                  inStream.read(buffer);
      inStream.close();
      
      // And make a string
      mSvgContent = 
      EncodingUtils.getString
       (buffer, "UTF-8");
      
      // Parse it
               mSvgId = SvgRaster.svgAndroidCreate();
               SvgRaster.svgAndroidParseBuffer
     (mSvgId, mSvgContent.toString());
               SvgRaster.svgAndroidSetAntialiasing(mSvgId, true);
                                                
               mIsSvg = true; 

      
     } catch (IOException e) {
      mIsSvg = false;
      e.printStackTrace();
     }                 
                }
            }
        }
}
 
Другая проблема в том, что в SVG не указан его размер(Не всегда, в некоторых указан желаемый. См. Пы.Сы.). Более тогоImageView параметры лэйаута могут быть установлены в wrap_content, fill_parent , или конкретные размеры. Используем метод onSizeChanged . Единственная проблема - атрибут wrap_content . В этом случае размер будет 0. Нуобходимо будет заменить wrap_content наfill_parent на лету. К сожалению, это ничего не даст. Если взглянуть на исходники, то будет видно, что родительски лэйаут вытаскивает параметры прямо из атрибутов и вызывает метод setLayoutParams . Перегрузим его:

@Override 
public void setLayoutParams(ViewGroup.LayoutParams params){
 if(mIsSvg)
 {
  // replace WRAP_CONTENT if needed
  if(params.width == ViewGroup.LayoutParams.WRAP_CONTENT
    && getSuggestedMinimumWidth() == 0)
   params.width = ViewGroup.LayoutParams.FILL_PARENT;
  if(params.height == ViewGroup.LayoutParams.WRAP_CONTENT
    && getSuggestedMinimumHeight() == 0)
   params.height = ViewGroup.LayoutParams.FILL_PARENT;
 }
 super.setLayoutParams(params);
}
 
Также onSizeChanged:

@Override 
public void onSizeChanged(int w, int h, int ow, int oh){
 if(mIsSvg){
  //Create the bitmap to raster svg to
   Canvas canvas = new Canvas();
  mBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
  canvas.setBitmap(mBitmap);
  // Render SVG with use of libandroidsvg
  SvgRaster.svgAndroidRenderToArea(
   mSvgId, canvas,
   0, 0, canvas.getWidth(), canvas.getHeight()); 
  this.setImageBitmap(mBitmap);
 }
 else
  super.onSizeChanged(w, h, ow, oh);
}
 


И, в конце-концов, настала пора опробовать то, что получилось. Создадим такой лэйаут:

<?xml version="1.0" encoding="utf-8"?>
 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:background="#AA0000"
  android:layout_height="fill_parent" 
  android:layout_width="fill_parent"
  android:layout_weight="1.0" 
  android:gravity="center"
  >
  <com.imageviewsvg.controls.ImageViewSvg
   android:src="@drawable/lion" 
   android:layout_width="100dip"
   android:layout_height="100dip" 
   android:id="@+id/svgview"
   android:layout_gravity="center" 
   />
 </LinearLayout>
 

Запускаемся:




Отладка

Для отладки с/с++ кода следуйте инструкциям Кароса Соунто: [9]. Сначала там не все ясно. Несколько советов:
  • При настройке C++ конфигурации, приложение действительно должно быть app_process, не обращаем внимания на жалобы эклипса, что такого файла нет, Он будет создан позже.
  • Необходимо каждый раз запускать ndk-gdb при запуске приложения. Иногда команда должна быть ndk-gdb –adb=/tools/adb –force.
  • Не забываем ставить "debuggable" в манифесте.

Другой подход. Anti Grain Geometry.

Это еще одна библиотека для растеризации SVG :[10]. Единственнаая дополнительная к ней библиотека - libexpat. Она уже есть в проекте. В папке jni создаем еще одну:



Копируем соответствующие файлы из исходников agg в gpc/include/src. Там, в папке examples есть папкаsvg_viewer. Копируем из нее все, за исключением svg_test в папкуaggsvg jni . Единственно, эта библиотека поддерживает только простые SVG. Необходимо будет дописывать парсер. В папке aggsvg-android , создаем файл aggsvgandroid.cpp. Пример парсит SVG из файловой системы. Для строки, добавляем следующий метод в класс parser :

void parser::parse(const char *chars, int length){

     char msg[1024];

     XML_Parser p = XML_ParserCreate(NULL);
     if(p == 0)
     {
      throw exception("Couldn't allocate memory for parser");
     }

     XML_SetParamEntityParsing(p, XML_PARAM_ENTITY_PARSING_ALWAYS);
     XML_UseForeignDTD(p, true);

     XML_SetUserData(p, this);
     XML_SetElementHandler(p, start_element, end_element);
     XML_SetCharacterDataHandler(p, content);

     int done = 0;
     std::string str = std::string(chars);
     std::istringstream inputString(str);

     while(true){
      if(done)
       break;
            size_t len = inputString.readsome(m_buf, buf_size);
            done = len < buf_size;
            if(!XML_Parse(p, m_buf, len, done))
            {
                sprintf(msg,
                    "%s at line %d\n",
                    XML_ErrorString(XML_GetErrorCode(p)),
                    (int)XML_GetCurrentLineNumber(p));
                throw exception(msg);
            }
     }
        XML_ParserFree(p);

        char* ts = m_title;
        while(*ts)
        {
            if(*ts < ' ') *ts = ' ';
            ++ts;
        }
    }
 

В конце файла Android.mk , добавляем секцию для построения
еще одной библиотеки. Все довольно просто. ПРосто очищаем переменные после построения первой
библиотеки и настраиваем их для построения второй.
Класс для растеризации с использованием AGG:

class SvgRasterizer{
 agg::svg::path_renderer m_path;
    double m_min_x;
    double m_min_y;
    double m_max_x;
    double m_max_y;
    double m_x;
    double m_y;
    pix_format_e pixformat;
 agg::rendering_buffer m_rbuf_window;

public:
 SvgRasterizer(pix_format_e format, uint32_t width, 
   uint32_t height, void *pixels) : \
  m_path(), \
  m_min_x(0.0), \
  m_min_y(0.0), \
  m_max_x(0.0), \
  m_max_y(0.0), \
  pixformat(format)
 {
  m_rbuf_window.attach((unsigned char*)pixels, width, height, 4*width);
 }

 void parse_svg(const char* svg, int length){
  // Create parser
  agg::svg::parser p(m_path);
  // Parse SVG
  p.parse(svg, length);
  // Make all polygons CCW-oriented
  m_path.arrange_orientations();
  // Get bounds of the image defined in SVG
        m_path.bounding_rect(&m_min_x, &m_min_y, &m_max_x, &m_max_y);
 }

 void rasterize_svg()
 {
  typedef agg::pixfmt_rgba32 pixfmt;
  typedef agg::renderer_base<pixfmt> renderer_base;
  typedef agg::renderer_scanline_aa_solid<renderer_base> renderer_solid;

        pixfmt pixf(m_rbuf_window);
        renderer_base rb(pixf);
        renderer_solid ren(rb);

        agg::rasterizer_scanline_aa<> ras;
        agg::scanline_p8 sl;
        agg::trans_affine mtx;

        double scl;
        // Calculate the scale the image to fit given bitmap
        if(m_max_y > m_max_x)
         scl = pixf.height()/m_max_y;
        else
         scl = pixf.width()/m_max_x;

        // Default gamma as is
        ras.gamma(agg::gamma_power(1.0));
        mtx *= agg::trans_affine_scaling(scl);

        m_path.expand(0.0);

        // Render image
        m_path.render(ras, sl, ren, mtx, rb.clip_box(), 1.0);

        ras.gamma(agg::gamma_none());
 }
};
 

В исходниках я добавил возможность протестировать обе библиотеки:




Заключение

Итак, имеется по меньшей мере два способа использования SVG в Андроиде. Основное преимущество libsvg-android в том, что она готова к использования, но более чем в три раза медленнее AGG, в которой сы вынуждены дорабатывать парсер. Помимо того, в AGG имеется масса возможностей для работы с изображениями. Я показал лишь как можно использовать ImageView в лэйауте, для программного использования необходимо будет еще перегрузить методы такие как setImageResource , к примеру.

На данный момент все, спасибо за внимание!


Ресурсы

  1. http://www.eclipse.org
  2. http://developer.motorola.com/docstools/motodevstudio
  3. http://developer.android.com/guide/developing/tools/adt.html
  4. http://www.eclipse.org/sequoyah/
  5. http://www.cygwin.com/
  6. http://developer.android.com/sdk/index.html
  7. http://www.crystax.net/en/android/ndk/6
  8. https://launchpad.net/libsvg-android
  9. http://www.eclipse.org/sequoyah/documentation/native_debug.php
  10. http://www.antigrain.com/



P.S. На Code Project вы также можете найти статью об использовании SVG как Drawable:
Drawable with SVG Support

Известные проблемы:

ibsvg-android не поддерживает представление цвета в виде rgb(255, 255, 255) (Inkscape). (По крайней мере на момент написания статьи.)
Проверьте файл и замените на fill: #ffffff

Возможно что-то еще. Проверяйте структуру SVG перед использованием.
Неподдреживаемые фичи будут выявляться только при отладке.


вторник, 25 октября 2011 г.

Анимированная заставка.


Анимированный сплеш-скрин для Android - приложения

English translation: Animated Splash Screen

Эта статья, написанная мной, изначально была опубликована на сайте Codeproject. Оригинал: Codeproject.

Исходники

Введение


     Каждому хочется, чтобы интерфейс его приложения выглядел привлекательно для пользователя. Множество программ, по крайней мере десктопных приложений (по большей части игры), используют заставки.  Это красиво и, более того, пока заставка находится на экране, вы можете инициализировать свое приложение. Существует много руководств, рассказывающих о том, как начать программировать под Андроид, нет смысла их повторять. Вы можете найти их в интернете в огромных количествах. Разберемся с тем, что касается конкретно программирования.


Начало.

Создайте новый проект в эклипсе со следующими настройками:

Project name : AdvancedSplashDemo
Build target:  Android 2.1
Application name: Advanced Splash Demo
Package name: Advanced Splash Demo
Create Activity: MainActivity – само приложение
 

Что мы имеем изначально - после того, как заставка отработала, она нам больше не нужна. Первая возникающая мысль - использовать активити, которая запускает основную и сама прекращает работу. Создадим лэйаут для заставки - просто LinearLayout с картинкой посередине. Создадим Android XML файл "splash.xml" в папке appfolder/res/layout.  Устанавливаем параметры в wrap_content, для того, чтобы это действительно выглядело как заставка. Выравниваем посередине:

 
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/TheSplashLayout"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center" >

    <ImageView
        android:id="@+id/SplashImageView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center" >
    </ImageView>
</LinearLayout>
 

Значение аттрибута "layout_gravity" выставлено в "center" для того, чтобы картинка была посередине экрана. Добавляем картинку в папку appfolder/res/drawable и жмем  F5 на проекте.
В исходниках есть файл lnxins.png, и вы можете установить его как источник для ImageView.

Теперь разберемся с манифестом. Сейчас в нем есть только “.MainActivity”, указанная как стартовая. Заменим ей категорию на категорию по умолчанию и добавим "SplashActivity". Она теперь будет являться стартовой. Для этого откроем в манифесте закладку "Application"  и для "MainActivity" изменим категорию в "default". Рядом с окном "Application Nodes" жмем кнопку "Add...", выбираем из списка "Activity" и жмем "Ok". Рядом с появившимся пунктом выбираем "Name*" гиперлинк и вводим имя - SplashScreen.  В исходниках автоматически создастся новый класс. Теперь рядом жмем кнопку "Add...", чтобы добавить intent filter, action - MAIN и category - Launcher. В результате SplashScreen activity будет запущена первой.

В итоге, манифест должен выглядеть следующим образом:


<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
      package="com.yourname.main"
      android:versionCode="1"
      android:versionName="1.0">
    <application android:icon="@drawable/icon" android:label="@string/app_name">
        <activity android:name=".MainActivity"
                  android:label="@string/app_name">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.DEFAULT"/>
            </intent-filter>
        </activity>    
        <activity android:name="SplashScreen">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"></action>
                <category android:name="android.intent.category.LAUNCHER"></category>
            </intent-filter>
        </activity>
    </application>
</manifest> 
 

Немного программирования

Откроем SplashScreen.java файл. На данный момент в нем имеется только один перегруженный метод - onCreate. Перегрузим также onTouchEvent для того, чтобы дать пользователю возможность закрыть заставку в любой момент. И не забываем о синхронизации, чтобы приложение не крэшилось в неподходящий момент. В результате имеем:

public class SplashScreen extends Activity {
    
    /**
     * Поток для обработки сообщений заставки
     */
    private Thread mSplashThread;    

    
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // Лэйаут заставки
        setContentView(R.layout.splash);
        
        final SplashScreen sPlashScreen = this;   
        
        // Поток для ожидания событий заставки
        mSplashThread =  new Thread(){
            @Override
            public void run(){
                try {
                    synchronized(this){
                        // Ждем некоторое время, или выход по прикосновению
                    wait(5000);
                    }
                }
                catch(InterruptedException ex){                    
                }

                finish();
                
                // Запускаем основную форму
                Intent intent = new Intent();
                intent.setClass(sPlashScreen, MainActivity.class);
                startActivity(intent);
                                  
            }
        };
        
        mSplashThread.start();        
    }
        
    @Override
    public boolean onTouchEvent(MotionEvent evt)
    {
        if(evt.getAction() == MotionEvent.ACTION_DOWN)
        {
            synchronized(mSplashThread){
                mSplashThread.notifyAll();
            }
        }
        return true;
    }    
}


Немного усовершенствований

Сначала сделаем фон заставки прозрачным. В  папке appfolder/res/values, добавим новый Android XML файл styles.xml и определим в нем тему для прозрачности:

<resources>
    <style name="Theme.Transparent" parent="android:Theme">
        <item name="android:windowIsTranslucent">true</item>
        <item name="android:windowBackground">@android:color/transparent</item>
        <item name="android:windowContentOverlay">@null</item>
        <item name="android:windowNoTitle">true</item>
        <item name="android:windowIsFloating">true</item>
        <item name="android:backgroundDimEnabled">false</item>
    </style>    
</resources>



Родительская тема - android:Theme, т.о. мы можем применить ее у к нашей активити. И, как вы видите, имена атрибутов довольно прозрачны, по ним можно понять их предназначение.
Далее, применим тему. В манифесте для SplashScreen activity установим "theme" атрибут:

<activity 
    android:name="SplashScreen"
    android:theme="@style/Theme.Transparent"            
>
    <intent-filter>
        <action android:name="android.intent.action.MAIN"></action>
        <category android:name="android.intent.category.LAUNCHER"></category>
    </intent-filter>
</activity> 


Предположим, что мы разрабатываем игру. Геймеры не очень любят, когда что-то отвлекает их внимание от игрового процесса. Большая их часть предпочитает играть в полноэкранном режиме.  Для MainActivity добавим тему для перехода в полноэкранный режим:

<activity android:name=".MainActivity"
          android:label="@string/app_name"
          android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
          >
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.DEFAULT"/>
    </intent-filter>
</activity>


Попробуем запустить то, что у нас получилось. Выглядит лучше. Теперь добавим плавное появление и исчезание. В папке appfolder/res еще одну - "anim" и добавим два Android XML файла – appear.xml и disappear.xml. Они будут определять альфа - анимацию.


Appear.xml

<set xmlns:android="http://schemas.android.com/apk/res/android">
    <alpha
        android:interpolator="@android:anim/accelerate_interpolator"
        android:fromAlpha="0.0" android:toAlpha="1.0"
        android:duration="800"
    />
</set>


Disappear.xml

<set xmlns:android="http://schemas.android.com/apk/res/android">
    <alpha
        android:interpolator="@android:anim/decelerate_interpolator"
        android:fromAlpha="1.0" android:toAlpha="0.0"
        android:duration="800"
    />
</set> 


Они просто изменяют альфа-канал объекта начиная с "fromAlpa" и заканчивая "toAlpha" в течение указанного промежутка времени. Добавим новый стиль в styles.xml:

<style name="Animations" parent="@android:Animation" />
    <style name="Animations.SplashScreen">
        <item name="android:windowEnterAnimation">@anim/appear</item>
        <item name="android:windowExitAnimation">@anim/disappear</item> 
    </style>
</style>

В итоге, "appear" анимация будет выполняться  при открытии окна, "disappear" - при закрытии. Добавим этот стиль к теме прозрачности:

<style name="Theme.Transparent" parent="android:Theme">
        ………
  <item name="android:windowAnimationStyle">
      @style/Animations.SplashScreen
  </item>
</style>


Зер гут, выглядит уже неплохо. И…

Не стреляйте в программиста, он рисует как может...

Теперь добавим еще немного анимации. Я художник от слова худо, поэтому использовал Script-Fu скрипт в Гимпе, чтобы сгенерировать хоть какую-то анимацию. Для начала убираем android:src атрибут в splash.xml. Теперь, в папке "drawable" , создадим flag.xml:

<?xml version="1.0" encoding="utf-8"?>
<animation-list     
    xmlns:android="http://schemas.android.com/apk/res/android" 
    android:id="@+id/flaganim"
    android:oneshot="false"
    >
    <item android:drawable="@drawable/f03" android:duration="100" />
    <item android:drawable="@drawable/f04" android:duration="100" />
    <item android:drawable="@drawable/f05" android:duration="100" />
    <item android:drawable="@drawable/f06" android:duration="100" />
    <item android:drawable="@drawable/f07" android:duration="100" />
    <item android:drawable="@drawable/f08" android:duration="100" />
    <item android:drawable="@drawable/f09" android:duration="100" />
    <item android:drawable="@drawable/f10" android:duration="100" />    
</animation-list>

Здесь просто набор фреймов, и "oneshot" атрибут говорит о том, что они будут повторяться в цикле. Чтобы запустить данную анимацию изменяем класс заставки - в "onCreate" методе меняем:

final ImageView splashImageView = 
        (ImageView) findViewById(R.id.SplashImageView);
 splashImageView.setBackgroundResource(R.drawable.flag);
 final AnimationDrawable frameAnimation = 
              (AnimationDrawable)splashImageView.getBackground(); 


Мы установили анимацию для заставки, но есть небольшая проблема - мы не можем запустить ее из метода "onCreate". Анимация должна быть запущена из GUI потока.  Для этого используем  “post” метод ImageView класса. Это добавить наш "Runnable" объект к очереди сообщений и , когда GUI поток будет доступен, запустит его:

splashImageView.post(new Runnable(){
            @Override
            public void run() {
                frameAnimation.start();                
            }            
        });


В итоге:


Это все. И программируем под Андроид в свое удовольствие :)

Спасибо за внимание!