blob: d456b9948b43d6d8d93dd1613ef77ba842812815 [file] [log] [blame]
// Copyright 2010-2014, Google Inc.
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package org.mozc.android.inputmethod.japanese;
import org.mozc.android.inputmethod.japanese.accessibility.AccessibilityUtil;
import org.mozc.android.inputmethod.japanese.accessibility.CandidateWindowAccessibilityDelegate;
import org.mozc.android.inputmethod.japanese.emoji.EmojiProviderType;
import org.mozc.android.inputmethod.japanese.keyboard.BackgroundDrawableFactory;
import org.mozc.android.inputmethod.japanese.keyboard.BackgroundDrawableFactory.DrawableType;
import org.mozc.android.inputmethod.japanese.protobuf.ProtoCandidates.CandidateList;
import org.mozc.android.inputmethod.japanese.protobuf.ProtoCandidates.CandidateWord;
import org.mozc.android.inputmethod.japanese.ui.CandidateLayout;
import org.mozc.android.inputmethod.japanese.ui.CandidateLayout.Row;
import org.mozc.android.inputmethod.japanese.ui.CandidateLayout.Span;
import org.mozc.android.inputmethod.japanese.ui.CandidateLayoutRenderer;
import org.mozc.android.inputmethod.japanese.ui.CandidateLayouter;
import org.mozc.android.inputmethod.japanese.ui.SnapScroller;
import org.mozc.android.inputmethod.japanese.view.SkinType;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Paint.Align;
import android.graphics.RectF;
import android.support.v4.view.ViewCompat;
import android.support.v4.widget.EdgeEffectCompat;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.GestureDetector.SimpleOnGestureListener;
import android.view.MotionEvent;
import android.view.View;
import javax.annotation.Nullable;
/**
* A view for candidate words.
*
*/
// TODO(matsuzakit): Optional is introduced partially. Complete introduction.
abstract class CandidateWordView extends View implements MemoryManageable {
/**
* Handles gestures to scroll candidate list and choose a candidate.
*/
class CandidateWordGestureDetector {
class CandidateWordViewGestureListener extends SimpleOnGestureListener {
@Override
public boolean onFling(
MotionEvent event1, MotionEvent event2, float velocityX, float velocityY) {
float velocity = orientationTrait.projectVector(velocityX, velocityY);
// As fling is started, current action is not tapping.
// Reset pressing state so that candidate selection is not triggered at touch up event.
releaseCandidate();
// Fling makes scrolling.
scroller.fling(-(int) velocity);
invalidate();
return true;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
float distance = orientationTrait.projectVector(distanceX, distanceY);
int oldScrollPosition = scroller.getScrollPosition();
int oldMaxScrollPosition = scroller.getMaxScrollPosition();
scroller.scrollBy((int) distance);
orientationTrait.scrollTo(CandidateWordView.this, scroller.getScrollPosition());
// As scroll is started, current action is not tapping.
// Reset pressing state so that candidate selection is not triggered at touch up event.
releaseCandidate();
// Edge effect. Now, in production, we only support vertical scroll.
if (oldScrollPosition + distance < 0) {
topEdgeEffect.onPull(distance / getHeight());
if (!bottomEdgeEffect.isFinished()) {
bottomEdgeEffect.onRelease();
}
} else if (oldScrollPosition + distance > oldMaxScrollPosition) {
bottomEdgeEffect.onPull(distance / getHeight());
if (!topEdgeEffect.isFinished()) {
topEdgeEffect.onRelease();
}
}
invalidate();
return true;
}
}
// GestureDetector cannot handle all complex gestures which we need.
// But we use GestureDetector for some gesture recognition
// because implementing whole gesture detection logic by ourselves is a bit tedious.
final GestureDetector gestureDetector;
/**
* Points to an instance of currently pressed candidate word. Or {@code null} if any
* candidates aren't pressed.
*/
@Nullable
private CandidateWord pressedCandidate;
private final RectF candidateRect = new RectF();
private Optional<Integer> pressedRowIndex = Optional.absent();
public CandidateWordGestureDetector(Context context) {
gestureDetector = new GestureDetector(context, new CandidateWordViewGestureListener());
}
private void pressCandidate(int rowIndex, Span span) {
Row row = calculatedLayout.getRowList().get(rowIndex);
pressedRowIndex = Optional.of(rowIndex);
pressedCandidate = span.getCandidateWord().orNull();
// TODO(yamaguchi):maybe better to make this rect larger by several pixels to avoid that
// users fail to select a candidate by unconscious small movement of tap point.
// (i.e. give hysterisis for noise reduction)
// Needs UX study.
candidateRect.set(span.getLeft(), row.getTop(),
span.getRight(), row.getTop() + row.getHeight());
}
private void releaseCandidate() {
pressedCandidate = null;
pressedRowIndex = Optional.absent();
}
CandidateWord getPressedCandidate() {
return pressedCandidate;
}
/**
* Checks if a down event is fired inside a candidate rectangle.
* If so, begin pressing it.
*
* It is assumed that rows are stored in up-to-down order,
* and spans are in left-to-right order.
*
* @param scrolledX X coordinate of down event point including scroll offset
* @param scrolledY Y coordinate of down event point including scroll offset
* @return true if the down event is fired inside a candidate rectangle.
*/
private boolean findCandidateAndPress(float scrolledX, float scrolledY) {
if (calculatedLayout == null) {
return false;
}
for (int rowIndex = 0; rowIndex < calculatedLayout.getRowList().size(); ++rowIndex) {
Row row = calculatedLayout.getRowList().get(rowIndex);
if (scrolledY < row.getTop()) {
break;
}
if (scrolledY >= row.getTop() + row.getHeight()) {
continue;
}
for (Span span : row.getSpanList()) {
if (scrolledX < span.getLeft()) {
break;
}
if (scrolledX >= span.getRight()) {
continue;
}
pressCandidate(rowIndex, span);
invalidate();
return true;
}
return false;
}
return false;
}
boolean onTouchEvent(MotionEvent event) {
if (gestureDetector.onTouchEvent(event)) {
return true;
}
float scrolledX = event.getX() + getScrollX();
float scrolledY = event.getY() + getScrollY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
findCandidateAndPress(scrolledX, scrolledY);
scroller.stopScrolling();
if (!topEdgeEffect.isFinished()) {
topEdgeEffect.onRelease();
}
if (!bottomEdgeEffect.isFinished()) {
bottomEdgeEffect.onRelease();
}
return true;
case MotionEvent.ACTION_MOVE:
if (pressedCandidate != null) {
// Turn off highlighting if contact point gets out of the candidate.
if (!candidateRect.contains(scrolledX, scrolledY)) {
releaseCandidate();
invalidate();
}
}
return true;
case MotionEvent.ACTION_CANCEL:
if (pressedCandidate != null) {
releaseCandidate();
invalidate();
}
return true;
case MotionEvent.ACTION_UP:
if (pressedCandidate != null) {
if (candidateRect.contains(scrolledX, scrolledY) && candidateSelectListener != null) {
candidateSelectListener.onCandidateSelected(pressedCandidate, pressedRowIndex);
}
releaseCandidate();
invalidate();
}
return true;
}
return false;
}
}
/**
* Polymorphic behavior based on scroll orientation.
*/
// TODO(hidehiko): rename OrientationTrait to OrientationTraits.
interface OrientationTrait {
/** @return scroll position of which direction corresponds to the orientation. */
int getScrollPosition(View view);
/** @return the projected value. */
float projectVector(float x, float y);
/** Scrolls to {@code position}. {@code position} is applied to corresponding axis. */
void scrollTo(View view, int position);
/** @return left or top position based on the orientation. */
float getCandidatePosition(Row row, Span span);
/** @return width or height based on the orientation. */
float getCandidateLength(Row row, Span span);
/** @return view's width or height based on the orientation. */
int getViewLength(View view);
/** @return the page size of the layout for the scroll orientation. */
int getPageSize(CandidateLayouter layouter);
/** @return the content size for the scroll orientation of the layout. 0 for absent. */
float getContentSize(Optional<CandidateLayout> layout);
}
enum Orientation implements OrientationTrait {
HORIZONTAL {
@Override
public int getScrollPosition(View view) {
return view.getScrollX();
}
@Override
public void scrollTo(View view, int position) {
view.scrollTo(position, 0);
}
@Override
public float getCandidatePosition(Row row, Span span) {
return span.getLeft();
}
@Override
public float getCandidateLength(Row row, Span span) {
return span.getWidth();
}
@Override
public int getViewLength(View view) {
return view.getWidth();
}
@Override
public float projectVector(float x, float y) {
return x;
}
@Override
public int getPageSize(CandidateLayouter layouter) {
return Preconditions.checkNotNull(layouter).getPageWidth();
}
@Override
public float getContentSize(Optional<CandidateLayout> layout) {
return layout.isPresent() ? layout.get().getContentWidth() : 0;
}
},
VERTICAL {
@Override
public int getScrollPosition(View view) {
return view.getScrollY();
}
@Override
public void scrollTo(View view, int position) {
view.scrollTo(0, position);
}
@Override
public float getCandidatePosition(Row row, Span span) {
return row.getTop();
}
@Override
public float getCandidateLength(Row row, Span span) {
return row.getHeight();
}
@Override
public int getViewLength(View view) {
return view.getHeight();
}
@Override
public float projectVector(float x, float y) {
return y;
}
@Override
public int getPageSize(CandidateLayouter layouter) {
return Preconditions.checkNotNull(layouter).getPageHeight();
}
@Override
public float getContentSize(Optional<CandidateLayout> layout) {
return layout.isPresent() ? layout.get().getContentHeight() : 0;
}
};
}
private CandidateSelectListener candidateSelectListener;
// Finally, we only need vertical scrolling.
// TODO(hidehiko): Remove horizontal scrolling related codes.
private final EdgeEffectCompat topEdgeEffect = new EdgeEffectCompat(getContext());
private final EdgeEffectCompat bottomEdgeEffect = new EdgeEffectCompat(getContext());
// The Scroller which manages the status of scrolling the view.
// Default behavior of ScrollView does not suffice our UX design
// so we introduced this Scroller.
// TODO(matsuzakit): The parameter is TBD (needs UX study?).
protected final SnapScroller scroller = new SnapScroller();
// The CandidateLayouter which calculates the layout of candidate words.
// This fields is not final but must be set in initialization in the subclasses.
@VisibleForTesting CandidateLayouter layouter;
// The calculated layout, created by this.layouter.
protected CandidateLayout calculatedLayout;
// The CandidateList which is currently shown on the view.
protected CandidateList currentCandidateList;
// The Y position where the last touch event occurs.
float lastEventPosition;
// No padding by default.
private int horizontalPadding = 0;
protected final CandidateLayoutRenderer candidateLayoutRenderer =
new CandidateLayoutRenderer(this);
CandidateWordGestureDetector candidateWordGestureDetector =
new CandidateWordGestureDetector(getContext());
// Scroll orientation.
private final OrientationTrait orientationTrait;
protected final BackgroundDrawableFactory backgroundDrawableFactory =
new BackgroundDrawableFactory(getResources().getDisplayMetrics().density);
private DrawableType backgroundDrawableType = null;
private final CandidateWindowAccessibilityDelegate accessibilityDelegate;
CandidateWordView(Context context, OrientationTrait orientationFeature) {
super(context);
this.orientationTrait = orientationFeature;
}
CandidateWordView(Context context, AttributeSet attributeSet,
OrientationTrait orientationTrait) {
super(context, attributeSet);
this.orientationTrait = orientationTrait;
}
CandidateWordView(Context context, AttributeSet attributeSet, int defaultStyle,
OrientationTrait orientationTrait) {
super(context, attributeSet, defaultStyle);
this.orientationTrait = orientationTrait;
}
{
accessibilityDelegate = new CandidateWindowAccessibilityDelegate(this);
ViewCompat.setAccessibilityDelegate(this, accessibilityDelegate);
}
void setCandidateSelectListener(CandidateSelectListener candidateSelectListener) {
this.candidateSelectListener = candidateSelectListener;
}
CandidateLayouter getCandidateLayouter() {
return layouter;
}
protected void setHorizontalPadding(int horizontalPadding) {
this.horizontalPadding = horizontalPadding;
updateLayouter();
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
int width = Math.max(right - left - horizontalPadding * 2, 0);
int height = bottom - top;
if (layouter != null && layouter.setViewSize(width, height)) {
updateCalculatedLayout();
}
updateScroller();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
return candidateWordGestureDetector.onTouchEvent(event);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
candidateLayoutRenderer.onAttachedToWindow();
}
@Override
protected void onDetachedFromWindow() {
candidateLayoutRenderer.onDetachedFromWindow();
super.onDetachedFromWindow();
}
public void setEmojiProviderType(EmojiProviderType providerType) {
Preconditions.checkNotNull(providerType);
candidateLayoutRenderer.setEmojiProviderType(providerType);
}
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
// Render edge effect.
boolean postInvalidateIsNeeded = false;
if (!topEdgeEffect.isFinished()) {
int saveCount = canvas.save();
try {
canvas.translate(0, Math.min(0, getScrollY()));
topEdgeEffect.setSize(getWidth(), getHeight());
if (topEdgeEffect.draw(canvas)) {
postInvalidateIsNeeded = true;
}
} finally {
canvas.restoreToCount(saveCount);
}
}
if (!bottomEdgeEffect.isFinished()) {
int saveCount = canvas.save();
try {
int width = getWidth();
int height = getHeight();
canvas.translate(-width, getScrollY() + height);
canvas.rotate(180, width, 0);
bottomEdgeEffect.setSize(width, height);
if (bottomEdgeEffect.draw(canvas)) {
postInvalidateIsNeeded = true;
}
} finally {
canvas.restoreToCount(saveCount);
}
}
if (postInvalidateIsNeeded) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (calculatedLayout == null || currentCandidateList == null) {
// No layout is available.
return;
}
// Paint the candidates.
int saveCount = canvas.save();
try {
canvas.translate(horizontalPadding, 0);
CandidateWord pressedCandidate = candidateWordGestureDetector.getPressedCandidate();
int pressedCandidateIndex = (pressedCandidate != null && pressedCandidate.hasIndex())
? pressedCandidate.getIndex() : -1;
candidateLayoutRenderer.drawCandidateLayout(canvas, calculatedLayout, pressedCandidateIndex);
} finally {
canvas.restoreToCount(saveCount);
}
}
@Override
public final void computeScroll() {
if (scroller.isScrolling()) {
// If still scrolling, update the scroll position and invalidate the window.
Optional<Float> optionalVelocity = scroller.computeScrollOffset();
orientationTrait.scrollTo(this, scroller.getScrollPosition());
if (optionalVelocity.isPresent()) {
Float velocity = optionalVelocity.get();
// The end of scrolling. Check edge effect.
if (velocity < 0) {
topEdgeEffect.onAbsorb(velocity.intValue());
if (!bottomEdgeEffect.isFinished()) {
bottomEdgeEffect.onRelease();
}
} else if (velocity > 0) {
bottomEdgeEffect.onAbsorb(velocity.intValue());
if (!topEdgeEffect.isFinished()) {
topEdgeEffect.onRelease();
}
}
}
// This invalidation makes next scrolling.
ViewCompat.postInvalidateOnAnimation(this);
}
super.computeScroll();
}
@VisibleForTesting int getUpdatedScrollPosition(Row row, Span span) {
int scrollPosition = orientationTrait.getScrollPosition(this);
float candidatePosition = orientationTrait.getCandidatePosition(row, span);
float candidateLength = orientationTrait.getCandidateLength(row, span);
int viewLength = orientationTrait.getViewLength(this);
if (candidatePosition < scrollPosition ||
candidatePosition + candidateLength > scrollPosition + viewLength) {
return (int) candidatePosition;
} else {
return scrollPosition;
}
}
/**
* If focused candidate is invisible (including partial invisible),
* update scroll position to see the candidate.
*/
protected void updateScrollPositionBasedOnFocusedIndex() {
int scrollPosition = 0;
if (calculatedLayout != null && currentCandidateList != null) {
int focusedIndex = currentCandidateList.getFocusedIndex();
row_loop: for (Row row : calculatedLayout.getRowList()) {
for (Span span : row.getSpanList()) {
if (!span.getCandidateWord().isPresent()) {
continue;
}
if (span.getCandidateWord().get().getIndex() == focusedIndex) {
scrollPosition = getUpdatedScrollPosition(row, span);
break row_loop;
}
}
}
}
setScrollPosition(scrollPosition);
}
void setScrollPosition(int position) {
scroller.scrollTo(position);
orientationTrait.scrollTo(this, scroller.getScrollPosition());
invalidate();
}
void update(CandidateList candidateList) {
CandidateList previousCandidateList = currentCandidateList;
currentCandidateList = candidateList;
candidateLayoutRenderer.setCandidateList(Optional.fromNullable(candidateList));
if (layouter != null && !equals(candidateList, previousCandidateList)) {
updateCalculatedLayout();
}
updateScroller();
invalidate();
}
private static boolean equals(CandidateList list1, CandidateList list2) {
if (list1 == list2) {
return true;
}
if (list1 == null || list2 == null) {
return false;
}
return list1.getCandidatesList().equals(list2.getCandidatesList());
}
/**
* Updates the layouter, and also updates the calculatedLayout based on the updated layouter.
*
* TODO(hidehiko): This method is remaining here to reduce a CL size smaller
* in order to make refactoring step by step. This will be cleaned when CandidateWordView
* is refactored.
*/
protected final void updateLayouter() {
updateCalculatedLayout();
updateScroller();
}
/**
* Updates the calculatedLayout if possible.
*/
private void updateCalculatedLayout() {
if (currentCandidateList == null || layouter == null) {
calculatedLayout = null;
} else {
calculatedLayout = layouter.layout(currentCandidateList).orNull();
}
Optional<CandidateLayout> candidateLayout = Optional.fromNullable(calculatedLayout);
accessibilityDelegate.setCandidateLayout(
candidateLayout,
(int) orientationTrait.getContentSize(candidateLayout),
orientationTrait.getViewLength(this));
}
private void updateScroller() {
if (calculatedLayout == null || layouter == null) {
scroller.setPageSize(0);
scroller.setContentSize(0);
} else {
int pageSize = orientationTrait.getPageSize(layouter);
int contentSize =
(int) orientationTrait.getContentSize(Optional.fromNullable(calculatedLayout));
if (pageSize != 0) {
// Ceil to align pages.
contentSize = (contentSize + pageSize - 1) / pageSize * pageSize;
}
scroller.setPageSize(pageSize);
scroller.setContentSize(contentSize);
}
scroller.setViewSize(orientationTrait.getViewLength(this));
}
public CandidateList getCandidateList() {
return currentCandidateList;
}
/**
* Utility method for creating paint instance.
*/
protected static Paint createPaint(
boolean antiAlias, int color, Align textAlign, float textSize) {
Paint paint = new Paint();
paint.setAntiAlias(antiAlias);
paint.setColor(color);
paint.setTextAlign(textAlign);
paint.setTextSize(textSize);
return paint;
}
protected void setBackgroundDrawableType(DrawableType drawableType) {
backgroundDrawableType = drawableType;
resetBackground();
}
private void resetBackground() {
candidateLayoutRenderer.setSpanBackgroundDrawable(
Optional.fromNullable(backgroundDrawableFactory.getDrawable(backgroundDrawableType)));
}
void setSkinType(SkinType skinType) {
backgroundDrawableFactory.setSkinType(skinType);
resetBackground();
}
@Override
public void trimMemory() {
calculatedLayout = null;
accessibilityDelegate.setCandidateLayout(Optional.<CandidateLayout>absent(), 0, 0);
currentCandidateList = null;
}
@Override
protected boolean dispatchHoverEvent(MotionEvent event) {
if (AccessibilityUtil.isTouchExplorationEnabled(getContext())) {
return accessibilityDelegate.dispatchHoverEvent(event);
}
return false;
}
}