63 | * ┏─────────┓ 64 | * ┃ x ┃ 65 | * ┃ x ┃ 66 | * ┃ x ┃ 67 | * ┃ x ┃ 68 | * ┃ x ┃ 69 | * ┃ x ┃ 70 | * ┃ x ┃ 71 | * ┗─────────┛ 72 | *73 | *
77 | * ┏─────────┓ 78 | * ┃ x ┃ 79 | * ┃ x ┃ 80 | * ┃ x ┃ 81 | * ┃ x ┃ 82 | * ┃ x ┃ 83 | * ┃ x ┃ 84 | * ┃ x ┃ 85 | * ┗─────────┛ 86 | *87 | *
88 | *
89 | * {@link Gravity#START}
90 | * {@link Orientation#HORIZONTAL}
91 | *
92 | * ┏─────────┓ 93 | * ┃x x┃ 94 | * ┃ x x ┃ 95 | * ┃ xxx ┃ 96 | * ┃ ┃ 97 | * ┃ ┃ 98 | * ┃ ┃ 99 | * ┃ ┃ 100 | * ┗─────────┛ 101 | *102 | *
103 | *
104 | * {@link Gravity#END}
105 | * {@link Orientation#HORIZONTAL}
106 | *
107 | * ┏─────────┓ 108 | * ┃ ┃ 109 | * ┃ ┃ 110 | * ┃ ┃ 111 | * ┃ ┃ 112 | * ┃ xxx ┃ 113 | * ┃ x x ┃ 114 | * ┃x x┃ 115 | * ┗─────────┛ 116 | *117 | * 118 | * @param gravity The {@link Gravity} that will define where the anchor point is for this layout manager. The 119 | * gravity point is the point around which items orbit. 120 | * @param orientation The orientation as defined in {@link RecyclerView}, and enforced by {@link Orientation} 121 | * @param radius The radius of the rotation angle, which helps define the curvature of the turn. This value 122 | * will be clamped to {@code [0, MAX_INT]} inclusive. 123 | * @param peekDistance The absolute extra distance from the {@link Gravity} edge after which this layout manager will start 124 | * placing items. This value will be clamped to {@code [0, radius]} inclusive. 125 | * @param rotate Should the items rotate as if on a turning surface, or should they maintain 126 | * their angle with respect to the screen as they orbit the center point? 127 | */ 128 | public TurnLayoutManager(Context context, 129 | @Gravity int gravity, 130 | @Orientation int orientation, 131 | @Dimension int radius, 132 | @Dimension int peekDistance, 133 | boolean rotate) { 134 | super(context, orientation, false); 135 | this.gravity = gravity; 136 | this.radius = Math.max(radius, MIN_RADIUS); 137 | this.peekDistance = Math.min(Math.max(peekDistance, MIN_PEEK), radius); 138 | this.rotate = rotate; 139 | this.center = new Point(); 140 | } 141 | 142 | /** 143 | * Create a {@link TurnLayoutManager} with default settings for gravity, orientation, and rotation. 144 | */ 145 | @SuppressWarnings("unused") 146 | public TurnLayoutManager(Context context, 147 | @Dimension int radius, 148 | @Dimension int peekDistance) { 149 | this(context, Gravity.START, Orientation.VERTICAL, radius, peekDistance, false); 150 | } 151 | 152 | public void setRadius(int radius) { 153 | this.radius = Math.max(radius, MIN_RADIUS); 154 | requestLayout(); 155 | } 156 | 157 | public void setPeekDistance(int peekDistance) { 158 | this.peekDistance = Math.min(Math.max(peekDistance, MIN_PEEK), radius); 159 | requestLayout(); 160 | } 161 | 162 | public void setGravity(@Gravity int gravity) { 163 | this.gravity = gravity; 164 | requestLayout(); 165 | } 166 | 167 | public void setRotate(boolean rotate) { 168 | this.rotate = rotate; 169 | requestLayout(); 170 | } 171 | 172 | @Override 173 | public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { 174 | int by = super.scrollVerticallyBy(dy, recycler, state); 175 | setChildOffsetsVertical(gravity, radius, center, peekDistance); 176 | return by; 177 | } 178 | 179 | @Override 180 | public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) { 181 | int by = super.scrollHorizontallyBy(dx, recycler, state); 182 | setChildOffsetsHorizontal(gravity, radius, center, peekDistance); 183 | return by; 184 | } 185 | 186 | @SuppressWarnings("ConstantConditions") 187 | @Override 188 | public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { 189 | super.onLayoutChildren(recycler, state); 190 | this.center = deriveCenter(gravity, getOrientation(), radius, peekDistance, center); 191 | setChildOffsets(gravity, getOrientation(), radius, center, peekDistance); 192 | } 193 | 194 | /** 195 | * Accounting for the settings of {@link Gravity} and {@link Orientation}, find the center point 196 | * around which this layout manager should arrange list items. Place the resulting coordinates 197 | * into {@code out}, to avoid reallocation. 198 | */ 199 | @SuppressWarnings("DuplicateExpressions") 200 | private Point deriveCenter(@Gravity int gravity, 201 | int orientation, 202 | @Dimension int radius, 203 | @Dimension int peekDistance, 204 | Point out) { 205 | final int gravitySign = gravity == Gravity.START ? -1 : 1; 206 | final int distanceMultiplier = gravity == Gravity.START ? 0 : 1; 207 | int x, y; 208 | switch (orientation) { 209 | case Orientation.HORIZONTAL: 210 | y = (distanceMultiplier * getHeight()) + gravitySign * (Math.abs(radius - peekDistance)); 211 | x = getWidth() / 2; 212 | break; 213 | case Orientation.VERTICAL: 214 | default: 215 | y = getHeight() / 2; 216 | x = (distanceMultiplier * getWidth()) + gravitySign * (Math.abs(radius - peekDistance)); 217 | break; 218 | } 219 | out.set(x, y); 220 | return out; 221 | } 222 | 223 | /** 224 | * Find the absolute horizontal distance by which a view at {@code viewY} should offset 225 | * to align with the circle {@code center} with {@code radius}, accounting for {@code peekDistance}. 226 | */ 227 | private double resolveOffsetX(double radius, double viewY, Point center, int peekDistance) { 228 | final double opposite = Math.abs(center.y - viewY); 229 | final double radiusSquared = radius * radius; 230 | final double oppositeSquared = opposite * opposite; 231 | final double adjacentSideLength = Math.sqrt(radiusSquared - oppositeSquared); 232 | return adjacentSideLength - radius + peekDistance; 233 | } 234 | 235 | /** 236 | * Find the absolute vertical distance by which a view at {@code viewX} should offset to 237 | * align with the circle {@code center} with {@code radius}, account for {@code peekDistance}. 238 | */ 239 | private double resolveOffsetY(double radius, double viewX, Point center, int peekDistance) { 240 | final double adjacent = Math.abs(center.x - viewX); 241 | final double radiusSquared = radius * radius; 242 | final double adjacentSquared = adjacent * adjacent; 243 | final double oppositeSideLength = Math.sqrt(radiusSquared - adjacentSquared); 244 | return oppositeSideLength - radius + peekDistance; 245 | } 246 | 247 | /** 248 | * Traffic method to divert calls based on {@link Orientation}. 249 | * 250 | * @see #setChildOffsetsVertical(int, int, Point, int) 251 | * @see #setChildOffsetsHorizontal(int, int, Point, int) 252 | */ 253 | private void setChildOffsets(@Gravity int gravity, 254 | int orientation, 255 | @Dimension int radius, 256 | Point center, 257 | int peekDistance) { 258 | if (orientation == VERTICAL) { 259 | setChildOffsetsVertical(gravity, radius, center, peekDistance); 260 | } else if (orientation == HORIZONTAL) { 261 | setChildOffsetsHorizontal(gravity, radius, center, peekDistance); 262 | } 263 | } 264 | 265 | /** 266 | * Set the bumper offsets on child views for {@link Orientation#VERTICAL} 267 | */ 268 | private void setChildOffsetsVertical(@Gravity int gravity, 269 | @Dimension int radius, 270 | Point center, 271 | int peekDistance) { 272 | for (int i = 0; i < getChildCount(); i++) { 273 | View child = getChildAt(i); 274 | if (child == null) continue; 275 | RecyclerView.LayoutParams layoutParams = (RecyclerView.LayoutParams) child.getLayoutParams(); 276 | final int xOffset = (int) resolveOffsetX(radius, child.getY() + child.getHeight() / 2.0f, center, peekDistance); 277 | final int x = gravity == Gravity.START ? xOffset + getMarginStart(layoutParams) 278 | : getWidth() - xOffset - child.getWidth() - getMarginStart(layoutParams); 279 | child.layout(x, child.getTop(), child.getWidth() + x, child.getBottom()); 280 | setChildRotationVertical(gravity, child, radius, center); 281 | } 282 | } 283 | 284 | /** 285 | * Given that the is {@link Orientation#VERTICAL}, apply rotation if rotation is enabled. 286 | */ 287 | private void setChildRotationVertical(@Gravity int gravity, View child, int radius, Point center) { 288 | if (!rotate) { 289 | child.setRotation(0); 290 | return; 291 | } 292 | boolean childPastCenter = (child.getY() + child.getHeight() / 2.0) > center.y; 293 | float directionMult; 294 | if (gravity == Gravity.END) { 295 | directionMult = childPastCenter ? -1 : 1; 296 | } else { 297 | directionMult = childPastCenter ? 1 : -1; 298 | } 299 | final float opposite = Math.abs(child.getY() + child.getHeight() / 2.0f - center.y); 300 | child.setRotation((float) (directionMult * Math.toDegrees(Math.asin(opposite / radius)))); 301 | } 302 | 303 | /** 304 | * Set bumper offsets on child views for {@link Orientation#HORIZONTAL} 305 | */ 306 | private void setChildOffsetsHorizontal(@Gravity int gravity, 307 | @Dimension int radius, 308 | Point center, 309 | int peekDistance) { 310 | for (int i = 0; i < getChildCount(); i++) { 311 | View child = getChildAt(i); 312 | if (child == null) continue; 313 | RecyclerView.LayoutParams layoutParams = (RecyclerView.LayoutParams) child.getLayoutParams(); 314 | final int yOffset = (int) resolveOffsetY(radius, child.getX() + child.getWidth() / 2.0f, center, peekDistance); 315 | final int y = gravity == Gravity.START ? yOffset + getMarginStart(layoutParams) 316 | : getHeight() - yOffset - child.getHeight() - getMarginStart(layoutParams); 317 | 318 | child.layout(child.getLeft(), y, child.getRight(), child.getHeight() + y); 319 | setChildRotationHorizontal(gravity, child, radius, center); 320 | } 321 | } 322 | 323 | /** 324 | * Given that the orientation is {@link Orientation#HORIZONTAL}, apply rotation if enabled. 325 | */ 326 | private void setChildRotationHorizontal(@Gravity int gravity, View child, int radius, Point center) { 327 | if (!rotate) { 328 | child.setRotation(0); 329 | return; 330 | } 331 | boolean childPastCenter = (child.getX() + child.getWidth() / 2.0) > center.x; 332 | float directionMult; 333 | if (gravity == Gravity.END) { 334 | directionMult = childPastCenter ? 1 : -1; 335 | } else { 336 | directionMult = childPastCenter ? -1 : 1; 337 | } 338 | final float opposite = Math.abs(child.getX() + child.getWidth() / 2.0f - center.x); 339 | child.setRotation((float) (directionMult * Math.toDegrees(Math.asin(opposite / radius)))); 340 | } 341 | 342 | /** 343 | * @see android.view.ViewGroup.MarginLayoutParams#getMarginStart() 344 | */ 345 | private int getMarginStart(ViewGroup.MarginLayoutParams layoutParams) { 346 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { 347 | return layoutParams.getMarginStart(); 348 | } 349 | return layoutParams.leftMargin; 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /turn/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 |