├── .github └── workflows │ ├── Check.yml │ └── Release.yml ├── .gitignore ├── .idea └── copyright │ ├── Square.xml │ └── profiles_settings.xml ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── RELEASING.md ├── build.gradle ├── contour-lint ├── build.gradle ├── gradle.properties └── src │ ├── main │ └── java │ │ └── com │ │ └── squareup │ │ └── contour │ │ └── lint │ │ ├── ContourIssueRegistry.kt │ │ └── NestedContourLayoutsDetector.kt │ └── test │ └── java │ └── com │ └── squareup │ └── contour │ └── lint │ └── NestedContourLayoutsDetectorTest.kt ├── contour ├── build.gradle ├── gradle.properties └── src │ ├── main │ ├── AndroidManifest.xml │ └── kotlin │ │ └── com │ │ └── squareup │ │ └── contour │ │ ├── Api.kt │ │ ├── ContourLayout.kt │ │ ├── Geometry.kt │ │ ├── SizeMode.kt │ │ ├── XFloat.kt │ │ ├── XInt.kt │ │ ├── YFloat.kt │ │ ├── YInt.kt │ │ ├── constraints │ │ ├── Constraint.kt │ │ ├── SizeConfig.kt │ │ └── SizeConfigSmartLambdas.kt │ │ ├── errors │ │ └── CircularReferenceDetected.kt │ │ ├── solvers │ │ ├── AxisSolver.kt │ │ ├── ComparisonResolver.kt │ │ └── SimpleAxisSolver.kt │ │ ├── utils │ │ ├── ViewGroups.kt │ │ └── XYIntUtils.kt │ │ └── wrappers │ │ ├── HasDimensions.kt │ │ └── ParentGeometry.kt │ └── test │ └── kotlin │ └── com │ └── squareup │ └── contour │ ├── ContourSizeConfigTests.kt │ ├── ContourTests.kt │ ├── XFloatTest.kt │ ├── XIntTest.kt │ ├── YFloatTest.kt │ ├── YIntTest.kt │ ├── utils │ ├── ContourTestHelpers.kt │ └── FakeTextView.kt │ └── wrappers │ └── ParentGeometryTest.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── sample-app ├── build.gradle ├── gradle.properties └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── squareup │ │ └── contour │ │ └── sample │ │ ├── CircularImageView.kt │ │ ├── Colors.kt │ │ ├── ExpandableBioCard1.kt │ │ ├── ExpandableBioCard2.kt │ │ ├── PushOnPressAnimator.kt │ │ ├── SampleActivity.kt │ │ ├── SampleView.kt │ │ └── widget │ │ └── PaddingAdjusterWidget.kt │ └── res │ └── drawable │ ├── android_logo.xml │ └── check_mark.xml ├── screenshots ├── crd.png ├── droidcon_talk_cover.png ├── runtime_layout_logic.gif └── simple_demo.gif └── settings.gradle /.github/workflows/Check.yml: -------------------------------------------------------------------------------- 1 | name: Check pull request 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | tags: 11 | - '*' 12 | 13 | jobs: 14 | macos-build: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout the repo 19 | uses: actions/checkout@v2 20 | - name: Build the repo 21 | run: ./gradlew clean build 22 | 23 | env: 24 | GRADLE_OPTS: -Dorg.gradle.configureondemand=true -Dorg.gradle.parallel=false -Dkotlin.incremental=false -Dorg.gradle.jvmargs="-Xmx3g -XX:MaxPermSize=2048m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8" 25 | -------------------------------------------------------------------------------- /.github/workflows/Release.yml: -------------------------------------------------------------------------------- 1 | name: Publish a release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - '*' 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repo 16 | uses: actions/checkout@v2.3.4 17 | 18 | - name: Publish artifacts 19 | run: ./gradlew publish --no-daemon 20 | env: 21 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }} 22 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} 23 | ORG_GRADLE_PROJECT_signingKey: ${{ secrets.ARTIFACT_SIGNING_PRIVATE_KEY }} 24 | 25 | env: 26 | GRADLE_OPTS: -Dorg.gradle.configureondemand=true -Dorg.gradle.parallel=false -Dkotlin.incremental=false -Dorg.gradle.jvmargs="-Xmx3g -XX:MaxPermSize=2048m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8" 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | local.properties 4 | **/.idea/* 5 | !/.idea/copyright/ 6 | 7 | .DS_Store 8 | build 9 | /captures 10 | reports -------------------------------------------------------------------------------- /.idea/copyright/Square.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/copyright/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 1.1.0 - _2021-03-02_ 4 | 5 | * Fix `compareTo` methods for `YInt` and `YFloat`. 6 | * Avoid measure both axis when we don't have to. 7 | * Support for live preview in Android Studio. 8 | * `layoutBy()` return the view it's applied to. You now can do 9 | 10 | ```kotlin 11 | val titleText : TextView 12 | 13 | init { 14 | titleText = TextView(context).apply { 15 | text = "Not sure what the title is yet" 16 | setTextColor(0x0) 17 | }.layoutBy( 18 | leftTo { parent.left() + 16.dip }, 19 | topTo { parent.top() + 16.dip }) 20 | } 21 | ``` 22 | 23 | * Update to Kotlin `1.4.30`. 24 | 25 | ## 1.0.0 - _2020-09-21_ 26 | 27 | * Rename `applyLayout()` to `layoutBy()`. 28 | * Add utility axis solvers: 29 | * [`contourWidthMatchParent()`](https://github.com/cashapp/contour/blob/e45ab4a625dbf37ec419d864ef7692c3c7bb01c7/contour/src/main/kotlin/com/squareup/contour/ContourLayout.kt#L307) and [`contourHeightMatchParent()`](https://github.com/cashapp/contour/blob/e45ab4a625dbf37ec419d864ef7692c3c7bb01c7/contour/src/main/kotlin/com/squareup/contour/ContourLayout.kt#L336) 30 | * [`contourWidthWrapContent()`](https://github.com/cashapp/contour/blob/e45ab4a625dbf37ec419d864ef7692c3c7bb01c7/contour/src/main/kotlin/com/squareup/contour/ContourLayout.kt#L328) and [`contourHeightWrapContent()`](https://github.com/cashapp/contour/blob/e45ab4a625dbf37ec419d864ef7692c3c7bb01c7/contour/src/main/kotlin/com/squareup/contour/ContourLayout.kt#L357) 31 | * [`matchXTo()`](https://github.com/cashapp/contour/blob/e45ab4a625dbf37ec419d864ef7692c3c7bb01c7/contour/src/main/kotlin/com/squareup/contour/ContourLayout.kt#L564) and [`matchYTo()`](https://github.com/cashapp/contour/blob/e45ab4a625dbf37ec419d864ef7692c3c7bb01c7/contour/src/main/kotlin/com/squareup/contour/ContourLayout.kt#L592) 32 | * [`matchParentX()`](https://github.com/cashapp/contour/blob/e45ab4a625dbf37ec419d864ef7692c3c7bb01c7/contour/src/main/kotlin/com/squareup/contour/ContourLayout.kt#L578) and [`matchParentY()`](https://github.com/cashapp/contour/blob/e45ab4a625dbf37ec419d864ef7692c3c7bb01c7/contour/src/main/kotlin/com/squareup/contour/ContourLayout.kt#L606) 33 | 34 | ### Breaking changes (for 0.1.7) 35 | * Add support for padding. This can be disabled on a per-View basis using [`respectPadding = false`](https://github.com/cashapp/contour/blob/e45ab4a625dbf37ec419d864ef7692c3c7bb01c7/contour/src/main/kotlin/com/squareup/contour/ContourLayout.kt#L176). 36 | * Remove `onInitializeLayout()` in favor of laying Views in `init`. 37 | 38 | ## 0.1.7 - _2020-05-19_ 39 | 40 | * Add `compareTo` operations to contour primitives. 41 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Contour [DEPRECATED] 2 | 3 | Contour is deprecated. It has been amazing. It is stable and probably still running in the wild. 4 | But its development has ceased. We encourage developers to favor [Jetpack Compose UI](https://developer.android.com/jetpack/compose). 5 | 6 | Contour is a typesafe, Kotlin-first API that aims to be the thinnest possible wrapper around Android’s layout APIs. It allows you to build compound views in pure Kotlin without using opaque layout rules - but instead by hooking into the layout phase yourself. The best comparison for Contour would be to ConstraintLayout - but instead of defining constraints in XML you actually provide them as executable lambdas. 7 | 8 | 9 | 10 | ### Deprecating XML 11 | 12 | XML had a good ride but times have changed and we should too. XML is a decent format for declaring static content - but that’s not what UIs are anymore. UIs increasingly have a ton of dynamic behavior - and trying to [jerry-rig](https://developer.android.com/topic/libraries/data-binding) XML layouts into adopting this dynamic behavior has taken things from bad to worse. What sort of dynamic behaviors do we expect from our UIs? 13 | 14 | - Change configuration based on screen size 15 | - Lazy loading elements of the UI. 16 | - A / B testing components at runtime 17 | - Dynamic theming 18 | - Add & remove components based on app state 19 | 20 | Let’s face it - XML is a static markup language and we’re trying to make it a full-fledged programming language. What’s the alternative? A full-fledged programming language! Kotlin! What sort of things do we get by abandoning the XML model? 21 | 22 | - No findViewById - view declaration exists in scope. 23 | - No R.id.my_view ambiguity - Kotlin has namespacing & encapsulation! 24 | - Static typing! 25 | - Great IDE Support (No more laggy xml editor) 26 | - ViewHolders aren’t needed 27 | - Easier DI 28 | 29 | ## Usage 30 | 31 | Similar to `ConstraintLayout`, Contour works by creating relationships between views' position and size. The difference is, instead of providing opaque XML attributes, you provide functions which are directly called during the layout phase. Contour calls these functions "axis solvers" and they're provided using `layoutBy()`. Here's an example: 32 | 33 | ```kotlin 34 | class BioView(context: Context) : ContourLayout(context) { 35 | private val avatar = ImageView(context).apply { 36 | scaleType = CENTER_CROP 37 | Picasso.get().load("https://i.imgur.com/ajdangY.jpg").into(this) 38 | } 39 | 40 | private val bio = TextView(context).apply { 41 | textSize = 16f 42 | text = "..." 43 | } 44 | 45 | init { 46 | avatar.layoutBy( 47 | x = leftTo { parent.left() }.widthOf { 60.xdip }, 48 | y = topTo { parent.top() }.heightOf { 60.ydip } 49 | ) 50 | bio.layoutBy( 51 | x = leftTo { avatar.right() + 16.xdip }.rightTo { parent.right() }, 52 | y = topTo { parent.top() } 53 | ) 54 | contourHeightOf { bio.bottom() } 55 | } 56 | } 57 | ``` 58 | 59 | #### Runtime Layout Logic 60 | 61 | Because you're writing plain Kotlin, you can reference other Views and use maths in your lambdas for describing your layout. These lambdas can also be used for making runtime decisions that will be evaluated on every layout pass. 62 | 63 | ```kotlin 64 | bio.layoutBy( 65 | x = ... 66 | y = topTo { 67 | if (isSelected) parent.top() + 16.ydip 68 | else avatar.centerY() - bio.height() / 2 69 | }.heightOf { 70 | if (isSelected) bio.preferredHeight() 71 | else 48.ydip 72 | } 73 | ) 74 | ``` 75 | 76 | When paired with an animator, your runtime layout decisions can even act as keyframes for smoothly updating your layout. 77 | 78 | ```kotlin 79 | setOnClickListener { 80 | TransitionManager.beginDelayedTransition(this) 81 | isSelected = !isSelected 82 | requestLayout() 83 | } 84 | ``` 85 | 86 | What does the end result of this look like? 87 | 88 | ![contourlayout animation](screenshots/runtime_layout_logic.gif) 89 | 90 | #### Context-Aware API 91 | Contour tries to make it easy to do the right thing. As part of this effort, all of the layout functions return interfaces as views of the correct available actions. 92 | 93 | For example, when defining a constraint of `leftTo`, the only exposed methods to chain in this layout are `rightTo` or `widthOf`. Another `leftTo`, or `centerHorizontallyTo` don't really make sense in this context and are hidden. In short: 94 | 95 | ```kotlin 96 | layoutBy( 97 | x = leftTo { name.left() }.leftTo { name.right() }, 98 | y = topTo { name.bottom() } 99 | ) 100 | ``` 101 | will not compile. 102 | 103 | #### Axis Type Safety 104 | 105 | Contour makes heavy use of inline classes to provide axis type safety in layouts. What this means is, 106 | 107 | ```kotlin 108 | toLeftOf { view.top() } 109 | ``` 110 | 111 | will not compile either. `toLeftOf {}` requires a `XInt`, and `top()` returns a `YInt`. In cases where this needs to be forced, casting functions are made available to `toY()` & `toX()`. 112 | 113 | Inline classes are a lightweight compile-time addition that allow this feature with minimal to no performance costs. 114 | https://kotlinlang.org/docs/reference/inline-classes.html 115 | 116 | ### Circular Reference Debugging 117 | 118 | Circular references are pretty easy to unintentionally introduce in any layout. To accidentally declare 119 | 120 | - `name.right` aligns to `note.left` 121 | - and `note.left` aligns to `name.right` 122 | 123 | Contour fails fast and loud when these errors are detected, and provides as much context as possible when doing so. The screenshot below is an example of the trace provided when a circular reference is detected. 124 | 125 |

126 | 127 |

128 | 129 | ## Comparison with Compose 130 | 131 | There is a lot of buzz and interest around writing views in code right now with the development of [Jetpack Compose](https://developer.android.com/jetpack/compose). Compose is a programmatic UI toolkit that uses reactive programming to drive the views. In contrast Contour doesn’t care about the update mechanisms - whether they be FRP or plain old imperative. Contour is only concerned with the nuts and bolts of view layouts - and making them as flexible and easy as possible. 132 | 133 | The only similarity between Contour and Compose is that they both realize writing layouts in Kotlin is maximum cool. 134 | 135 | ## Releases 136 | 137 | ```groovy 138 | implementation "app.cash.contour:contour:1.1.0" 139 | ``` 140 | 141 | Snapshots of the development version are available in [Sonatype's `snapshots` repository](https://oss.sonatype.org/content/repositories/snapshots/app/cash/contour/ ). 142 | 143 | ```groovy 144 | repositories { 145 | mavenCentral() 146 | maven { 147 | url 'https://oss.sonatype.org/content/repositories/snapshots/' 148 | } 149 | } 150 | ``` 151 | 152 | ## License 153 | 154 | ``` 155 | Copyright 2019 Square, Inc. 156 | 157 | Licensed under the Apache License, Version 2.0 (the "License"); 158 | you may not use this file except in compliance with the License. 159 | You may obtain a copy of the License at 160 | 161 | http://www.apache.org/licenses/LICENSE-2.0 162 | 163 | Unless required by applicable law or agreed to in writing, software 164 | distributed under the License is distributed on an "AS IS" BASIS, 165 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 166 | See the License for the specific language governing permissions and 167 | limitations under the License. 168 | ``` 169 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | Releasing 2 | ========= 3 | 4 | ### Prerequisite: Sonatype (Maven Central) Account 5 | 6 | Create an account on the [Sonatype issues site][sonatype_issues]. Ask an existing publisher to open 7 | an issue requesting publishing permissions for `app.cash` projects. 8 | 9 | ### Prerequisite: GPG Keys 10 | 11 | Generate a GPG key (RSA, 4096 bit, 3650 day) expiry, or use an existing one. You should leave the 12 | password empty for this key. 13 | 14 | ``` 15 | $ gpg --full-generate-key 16 | ``` 17 | 18 | Upload the GPG keys to public servers: 19 | 20 | ``` 21 | $ gpg --list-keys --keyid-format LONG 22 | /Users/johnbarber/.gnupg/pubring.kbx 23 | ------------------------------ 24 | pub rsa4096/XXXXXXXXXXXXXXXX 2019-07-16 [SC] [expires: 2029-07-13] 25 | YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY 26 | uid [ultimate] John Barber 27 | sub rsa4096/ZZZZZZZZZZZZZZZZ 2019-07-16 [E] [expires: 2029-07-13] 28 | 29 | $ gpg --send-keys --keyserver keyserver.ubuntu.com XXXXXXXXXXXXXXXX 30 | ``` 31 | 32 | ### Prerequisite: Gradle Properties 33 | 34 | Define publishing properties in `~/.gradle/gradle.properties`: 35 | 36 | ``` 37 | signing.keyId=1A2345F8 38 | signing.password= 39 | signing.secretKeyRingFile=/Users/jbarber/.gnupg/secring.gpg 40 | ``` 41 | 42 | `signing.keyId` is the GPG key's ID. Get it with this: 43 | 44 | ``` 45 | $ gpg --list-keys --keyid-format SHORT 46 | ``` 47 | 48 | `signing.password` is the password for this key. This might be empty! 49 | 50 | `signing.secretKeyRingFile` is the absolute path for `secring.gpg`. You may need to export this 51 | file manually with the following command where `XXXXXXXX` is the `keyId` above: 52 | 53 | ``` 54 | $ gpg --keyring secring.gpg --export-secret-key XXXXXXXX > ~/.gnupg/secring.gpg 55 | ``` 56 | 57 | 58 | Cutting a Release 59 | ----------------- 60 | 61 | 1. Update `CHANGELOG.md`. 62 | 63 | 2. Set versions: 64 | 65 | ``` 66 | export RELEASE_VERSION=X.Y.Z 67 | export NEXT_VERSION=X.Y.Z-SNAPSHOT 68 | ``` 69 | 70 | 3. Set environment variables with your [Sonatype credentials][sonatype_issues]. 71 | 72 | ``` 73 | export SONATYPE_NEXUS_USERNAME=johnbarber 74 | export SONATYPE_NEXUS_PASSWORD=`pbpaste` 75 | ``` 76 | 77 | 4. Update, build, and upload: 78 | 79 | ``` 80 | sed -i "" \ 81 | "s/VERSION_NAME=.*/VERSION_NAME=$RELEASE_VERSION/g" \ 82 | `find . -name "gradle.properties"` 83 | sed -i "" \ 84 | "s/\"app.cash.contour:\([^\:]*\):[^\"]*\"/\"app.cash.contour:\1:$RELEASE_VERSION\"/g" \ 85 | `find . -name "README.md"` 86 | ./gradlew clean uploadArchives 87 | ``` 88 | 89 | 5. Visit [Sonatype Nexus][sonatype_nexus] to promote (close then release) the artifact. Or drop it 90 | if there is a problem! 91 | 92 | 6. Tag the release, prepare for the next one, and push to GitHub. 93 | 94 | ``` 95 | git commit -am "Prepare for release $RELEASE_VERSION." 96 | git tag -a parent-$RELEASE_VERSION -m "Version $RELEASE_VERSION" 97 | sed -i "" \ 98 | "s/VERSION_NAME=.*/VERSION_NAME=$NEXT_VERSION/g" \ 99 | `find . -name "gradle.properties"` 100 | git commit -am "Prepare next development version." 101 | git push && git push --tags 102 | ``` 103 | 104 | [sonatype_issues]: https://issues.sonatype.org/ 105 | [sonatype_nexus]: https://oss.sonatype.org/ 106 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.github.ben-manes.versions' 2 | 3 | buildscript { 4 | ext.versions = [ 5 | 'minSdk': 21, 6 | 'targetSdk': 30, 7 | 'agp': '7.0.0', 8 | 'lint': '30.0.0', // = 23.0.0 + agp 9 | 'kotlin': '1.5.10', 10 | 'autoService': '1.0', 11 | ] 12 | 13 | ext.deps = [ 14 | 'androidx': [ 15 | 'appCompat': 'androidx.appcompat:appcompat:1.3.1', 16 | 'ktx': 'androidx.core:core-ktx:1.6.0', 17 | 'transition': 'androidx.transition:transition:1.4.1', 18 | ], 19 | 'picasso': 'com.squareup.picasso:picasso:2.8', 20 | 'lint': [ 21 | 'api': "com.android.tools.lint:lint-api:${versions.lint}", 22 | 'lint': "com.android.tools.lint:lint:${versions.lint}", 23 | 'tests': "com.android.tools.lint:lint-tests:${versions.lint}", 24 | ], 25 | 'auto': [ 26 | 'service': "com.google.auto.service:auto-service:${versions.autoService}", 27 | 'serviceAnnotations': "com.google.auto.service:auto-service-annotations:${versions.autoService}", 28 | ], 29 | 'junit': 'junit:junit:4.13.2', 30 | 'robolectric': 'org.robolectric:robolectric:4.6.1', 31 | 'truth': 'com.google.truth:truth:1.1.3', 32 | 'plugins': [ 33 | 'agp': "com.android.tools.build:gradle:${versions.agp}", 34 | 'kotlin': "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" 35 | ], 36 | ] 37 | 38 | dependencies { 39 | classpath deps.plugins.agp 40 | classpath deps.plugins.kotlin 41 | classpath 'com.github.ben-manes:gradle-versions-plugin:0.39.0' 42 | classpath 'com.vanniktech:gradle-maven-publish-plugin:0.17.0' 43 | } 44 | 45 | repositories { 46 | google() 47 | mavenCentral() 48 | gradlePluginPortal() 49 | } 50 | } 51 | 52 | subprojects { 53 | repositories { 54 | google() 55 | mavenCentral() 56 | } 57 | 58 | tasks.withType(JavaCompile).configureEach { task -> 59 | task.sourceCompatibility = JavaVersion.VERSION_1_8 60 | task.targetCompatibility = JavaVersion.VERSION_1_8 61 | } 62 | 63 | tasks.withType(org.jetbrains.kotlin.gradle.dsl.KotlinJvmCompile).configureEach { task -> 64 | task.kotlinOptions { 65 | jvmTarget = "1.8" 66 | } 67 | } 68 | } 69 | 70 | task clean(type: Delete) { 71 | delete rootProject.buildDir 72 | } 73 | 74 | subprojects { project -> 75 | tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { 76 | kotlinOptions { 77 | jvmTarget = "1.8" 78 | } 79 | } 80 | } 81 | 82 | tasks.wrapper { 83 | distributionType = Wrapper.DistributionType.BIN 84 | } -------------------------------------------------------------------------------- /contour-lint/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'java-library' 2 | apply plugin: 'org.jetbrains.kotlin.jvm' 3 | apply plugin: 'org.jetbrains.kotlin.kapt' 4 | apply plugin: 'com.android.lint' 5 | 6 | targetCompatibility = JavaVersion.VERSION_1_8 7 | sourceCompatibility = JavaVersion.VERSION_1_8 8 | 9 | dependencies { 10 | compileOnly deps.lint.api 11 | compileOnly deps.auto.serviceAnnotations 12 | kapt deps.auto.service 13 | testImplementation deps.junit 14 | testImplementation deps.lint.lint 15 | testImplementation deps.lint.tests 16 | } -------------------------------------------------------------------------------- /contour-lint/gradle.properties: -------------------------------------------------------------------------------- 1 | # needed so that :contour:prepareLintJarForPublish can succeed 2 | # Remove when the bug described in https://issuetracker.google.com/issues/161727305 is fixed 3 | kotlin.stdlib.default.dependency=false 4 | -------------------------------------------------------------------------------- /contour-lint/src/main/java/com/squareup/contour/lint/ContourIssueRegistry.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.lint 18 | 19 | import com.android.tools.lint.client.api.IssueRegistry 20 | import com.android.tools.lint.client.api.Vendor 21 | import com.android.tools.lint.detector.api.CURRENT_API 22 | import com.google.auto.service.AutoService 23 | 24 | @Suppress("UnstableApiUsage", "unused") 25 | @AutoService(value = [IssueRegistry::class]) 26 | class ContourIssueRegistry : IssueRegistry() { 27 | override val issues = listOf(NestedContourLayoutsDetector.ISSUE) 28 | 29 | override val api = CURRENT_API 30 | 31 | /** 32 | * works with Studio 4.0 or later; see 33 | * [com.android.tools.lint.detector.api.describeApi] 34 | */ 35 | override val minApi = 7 36 | 37 | override val vendor = Vendor( 38 | vendorName = "cashapp/contour", 39 | identifier = "app.cash.contour:contour:{version}", 40 | feedbackUrl = "https://github.com/cashapp/contour/issues", 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /contour-lint/src/main/java/com/squareup/contour/lint/NestedContourLayoutsDetector.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.lint 18 | 19 | import com.android.tools.lint.client.api.UElementHandler 20 | import com.android.tools.lint.detector.api.Category 21 | import com.android.tools.lint.detector.api.Detector 22 | import com.android.tools.lint.detector.api.Implementation 23 | import com.android.tools.lint.detector.api.Issue 24 | import com.android.tools.lint.detector.api.JavaContext 25 | import com.android.tools.lint.detector.api.Scope 26 | import com.android.tools.lint.detector.api.Severity.ERROR 27 | import com.intellij.psi.PsiClass 28 | import com.intellij.psi.PsiClassType 29 | import com.intellij.psi.PsiType 30 | import org.jetbrains.uast.UCallExpression 31 | import org.jetbrains.uast.UElement 32 | import org.jetbrains.uast.ULambdaExpression 33 | import org.jetbrains.uast.getContainingUClass 34 | import org.jetbrains.uast.getParentOfType 35 | import org.jetbrains.uast.getUCallExpression 36 | 37 | @Suppress("UnstableApiUsage") 38 | class NestedContourLayoutsDetector : Detector(), Detector.UastScanner { 39 | 40 | override fun getApplicableUastTypes(): List> = 41 | listOf(UCallExpression::class.java) 42 | 43 | override fun createUastHandler(context: JavaContext): UElementHandler { 44 | return object : UElementHandler() { 45 | override fun visitCallExpression(node: UCallExpression) { 46 | if (isContourLayoutFunction(node)) { 47 | check(context, node) 48 | } 49 | } 50 | } 51 | } 52 | 53 | private fun isContourLayoutFunction(node: UCallExpression): Boolean { 54 | if (node.methodName != "layoutBy" && node.methodName != "applyLayout") return false 55 | if (node.valueArgumentCount < 2) return false 56 | 57 | // Type resolution for kotlin classes doesn't work when lint 58 | // is run from the command line so node.resolve() will be null. 59 | val function = node.resolve() 60 | if (function == null) { 61 | // Skip the 0th param, which will be the type param (). 62 | val argType = { at: Int -> node.getArgumentForParameter(at)?.getExpressionType() } 63 | return isAxisSolver(argType(1)) && isAxisSolver(argType(2)) 64 | 65 | } else if (function.containingClass?.qualifiedName == "com.squareup.contour.ContourLayout") { 66 | val params = function.parameters 67 | return params[1]?.name == "x" && params[2]?.name == "y" 68 | } 69 | return false 70 | } 71 | 72 | private fun isAxisSolver(type: PsiType?): Boolean { 73 | if (type == null) return false 74 | if (type.canonicalText == "com.squareup.contour.solvers.AxisSolver") return true 75 | for (superT in type.superTypes) { 76 | if (isAxisSolver(superT)) return true 77 | } 78 | return false 79 | } 80 | 81 | private fun check(context: JavaContext, node: UCallExpression) { 82 | val isInsideLambda = node.getParentOfType() != null 83 | val isInsideContourView = isContourView(context, node.getContainingUClass()) 84 | if (!isInsideLambda || !isInsideContourView) { 85 | return 86 | } 87 | 88 | val methodReceiver = (node.getUCallExpression()?.receiverType as? PsiClassType)?.resolve() 89 | val containingView = node.getContainingUClass()!! 90 | 91 | if (!containingView.isEquivalentTo(methodReceiver)) { 92 | // Code example: 93 | // class BarView : ContourLayout 94 | // class FooView : ContourLayout { 95 | // val view = BarView(context).apply { 96 | // layout(x, y) <- ERROR! The receiver of this layout() call isn't FooView. 97 | // } 98 | // } 99 | val nestedContourView = methodReceiver?.name 100 | context.report( 101 | issue = ISSUE, 102 | scope = node, 103 | location = context.getNameLocation(node), 104 | message = "Calling `${node.methodName}()` on the wrong scope: `$nestedContourView` " + 105 | "instead of `${containingView.name}`. This will result in an infinite loop. " + 106 | "Consider using a lambda that offers `$nestedContourView` as an argument " + 107 | "(e.g., `also`, `let`) instead of a receiver (e.g., `with`, `apply`, `run`) or" + 108 | " moving this layout logic to `${containingView.name}`'s `init` block." 109 | ) 110 | } 111 | } 112 | 113 | private fun isContourView(context: JavaContext, type: PsiClass?): Boolean { 114 | return context.evaluator.extendsClass(type, "com.squareup.contour.ContourLayout", true) 115 | } 116 | 117 | companion object { 118 | val ISSUE = Issue.create( 119 | id = "NestedContourLayouts", 120 | briefDescription = "Incorrectly nested ContourLayouts", 121 | explanation = """ 122 | When a nested contour view is initialized and laid out using a lambda that offers \ 123 | `this` as a receiver (e.g., Kotlin's `apply{}` and `run{}`), it's easy to accidentally \ 124 | call `layoutBy()`/`applyLayout()` on the wrong scope. This lint flags these kinds of \ 125 | errors. Here's an example: 126 | 127 | ``` 128 | class FooView : ContourLayout { 129 | val view = BarView(context).apply { 130 | layoutBy(x, y) <- ERROR! The receiver of this call is BarView and not FooView! 131 | } 132 | } 133 | class BarView(context: Context) : ContourLayout 134 | ``` 135 | 136 | The above example result in a StackOverflowException because `FooView` ends up laying \ 137 | out itself rather than `BarView`. 138 | """.trimMargin(), 139 | category = Category.CORRECTNESS, 140 | severity = ERROR, 141 | priority = 5, 142 | implementation = Implementation( 143 | NestedContourLayoutsDetector::class.java, 144 | Scope.JAVA_FILE_SCOPE 145 | ) 146 | ) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /contour-lint/src/test/java/com/squareup/contour/lint/NestedContourLayoutsDetectorTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.lint 18 | 19 | import com.android.tools.lint.checks.infrastructure.LintDetectorTest.java 20 | import com.android.tools.lint.checks.infrastructure.LintDetectorTest.kotlin 21 | import com.android.tools.lint.checks.infrastructure.TestFile 22 | import com.android.tools.lint.checks.infrastructure.TestLintTask 23 | import org.junit.FixMethodOrder 24 | import org.junit.Test 25 | import org.junit.runners.MethodSorters.JVM 26 | 27 | @Suppress("UnstableApiUsage") 28 | @FixMethodOrder(JVM) 29 | class NestedContourLayoutsDetectorTest { 30 | 31 | private fun lint(source: TestFile): TestLintTask { 32 | return TestLintTask.lint().files( 33 | VIEW_STUB, 34 | AXIS_SOLVERS_STUB, 35 | CONTOUR_LAYOUT_STUB, 36 | source 37 | ) 38 | } 39 | 40 | @Test fun `nested contour views with 'this' as the receiver`() { 41 | val source = """ 42 | |package sample 43 | | 44 | |import android.content.Context 45 | |import com.squareup.contour.ContourLayout 46 | | 47 | |class FooView(context: Context) : ContourLayout(context) { 48 | | private val view1 = BarView(context).apply { 49 | | layoutBy( 50 | | x = matchParentX(), 51 | | y = matchParentY() 52 | | ) 53 | | } 54 | | private val view2 = with(BarView(context)) { 55 | | layoutBy( 56 | | x = matchParentX(), 57 | | y = matchParentY() 58 | | ) 59 | | } 60 | | private val view3 = BarView(context).run { 61 | | applyLayout( 62 | | x = matchParentX(), 63 | | y = matchParentY() 64 | | ) 65 | | } 66 | |} 67 | | 68 | |class BarView(context: Context) : ContourLayout(context) 69 | """.trimMargin() 70 | 71 | lint(kotlin(source)) 72 | .issues(NestedContourLayoutsDetector.ISSUE) 73 | .run() 74 | .expect( 75 | """ 76 | |src/sample/FooView.kt:8: Error: Calling layoutBy() on the wrong scope: BarView instead of FooView. This will result in an infinite loop. Consider using a lambda that offers BarView as an argument (e.g., also, let) instead of a receiver (e.g., with, apply, run) or moving this layout logic to FooView's init block. [NestedContourLayouts] 77 | | layoutBy( 78 | | ~~~~~~~~ 79 | |src/sample/FooView.kt:14: Error: Calling layoutBy() on the wrong scope: BarView instead of FooView. This will result in an infinite loop. Consider using a lambda that offers BarView as an argument (e.g., also, let) instead of a receiver (e.g., with, apply, run) or moving this layout logic to FooView's init block. [NestedContourLayouts] 80 | | layoutBy( 81 | | ~~~~~~~~ 82 | |src/sample/FooView.kt:20: Error: Calling applyLayout() on the wrong scope: BarView instead of FooView. This will result in an infinite loop. Consider using a lambda that offers BarView as an argument (e.g., also, let) instead of a receiver (e.g., with, apply, run) or moving this layout logic to FooView's init block. [NestedContourLayouts] 83 | | applyLayout( 84 | | ~~~~~~~~~~~ 85 | |3 errors, 0 warnings 86 | """.trimMargin() 87 | ) 88 | } 89 | 90 | @Test fun `nested contour views with 'this' as the argument`() { 91 | val source = """ 92 | |package sample 93 | | 94 | |import android.content.Context 95 | |import com.squareup.contour.ContourLayout 96 | | 97 | |class FooView(context: Context) : ContourLayout(context) { 98 | | private val view1 = BarView(context).let { 99 | | layoutBy( 100 | | x = leftTo { parent.left() }, 101 | | y = topTo { parent.top() } 102 | | ) 103 | | } 104 | | 105 | | private val view2 = BarView(context).also { 106 | | layoutBy( 107 | | x = rightTo { parent.right() }, 108 | | y = bottomTo { parent.bottom() } 109 | | ) 110 | | } 111 | |} 112 | | 113 | |class BarView(context: Context) : ContourLayout(context) 114 | """.trimMargin() 115 | 116 | lint(kotlin(source)) 117 | .issues(NestedContourLayoutsDetector.ISSUE) 118 | .run() 119 | .expectClean() 120 | } 121 | 122 | @Test fun `nested non-contour views`() { 123 | val source = """ 124 | |package sample 125 | | 126 | |import android.content.Context 127 | |import android.view.View 128 | |import com.squareup.contour.ContourLayout 129 | | 130 | |class FooView(context: Context) : ContourLayout(context) { 131 | | private val view = View(context).apply { 132 | | layoutBy( 133 | | x = leftTo { parent.left() }, 134 | | y = bottomTo { parent.bottom() } 135 | | ) 136 | | } 137 | |} 138 | """.trimMargin() 139 | 140 | lint(kotlin(source)) 141 | .issues(NestedContourLayoutsDetector.ISSUE) 142 | .run() 143 | .expectClean() 144 | } 145 | 146 | @Test fun `calling layoutBy outside a lambda`() { 147 | val source = """ 148 | |package sample 149 | | 150 | |import android.content.Context 151 | |import android.view.View 152 | |import com.squareup.contour.ContourLayout 153 | | 154 | |class FooView(context: Context) : ContourLayout(context) { 155 | | private val view1 = View(context) 156 | | private val view2 = BarView(context) 157 | | 158 | | init { 159 | | view1.layoutBy( 160 | | x = leftTo { parent.left() }, 161 | | y = topTo { parent.top() } 162 | | ) 163 | | 164 | | view2.layoutBy( 165 | | x = rightTo { parent.right() }, 166 | | y = bottomTo { parent.bottom() } 167 | | ) 168 | | } 169 | |} 170 | | 171 | |class BarView(context: Context) : ContourLayout(context) 172 | """.trimMargin() 173 | 174 | lint(kotlin(source)) 175 | .issues(NestedContourLayoutsDetector.ISSUE) 176 | .run() 177 | .expectClean() 178 | } 179 | 180 | @Test fun `nested contour views with complex class hierarchy`() { 181 | val source = """ 182 | |package sample 183 | | 184 | |import android.content.Context 185 | |import com.squareup.contour.ContourLayout 186 | | 187 | |class FooView(context: Context) : OnClickListener, ContourLayout(context) { 188 | | private val view = BarBarView(context).apply { 189 | | layoutBy( 190 | | x = matchParentX(), 191 | | y = matchParentY() 192 | | ) 193 | | } 194 | |} 195 | | 196 | |class BarBarView(context: Context) : OnClickListener, BarView(context) 197 | |open class BarView(context: Context) : ContourLayout(context) 198 | |interface OnClickListener 199 | """.trimMargin() 200 | 201 | lint(kotlin(source)) 202 | .issues(NestedContourLayoutsDetector.ISSUE) 203 | .run() 204 | .expectErrorCount(1) 205 | } 206 | 207 | companion object { 208 | private val AXIS_SOLVERS_STUB = kotlin( 209 | """ 210 | |package com.squareup.contour.solvers 211 | | 212 | |interface AxisSolver 213 | |interface XAxisSolver : AxisSolver 214 | |interface YAxisSolver : AxisSolver 215 | """.trimMargin() 216 | ) 217 | private val CONTOUR_LAYOUT_STUB = kotlin( 218 | """ 219 | |package com.squareup.contour 220 | | 221 | |import android.content.Context 222 | |import android.view.View 223 | |import android.view.ViewGroup 224 | |import com.squareup.contour.solvers.XAxisSolver 225 | |import com.squareup.contour.solvers.YAxisSolver 226 | | 227 | |open class ContourLayout(context: Context) : ViewGroup(context) { 228 | | fun View.layoutBy(x: XAxisSolver, y: YAxisSolver): Unit = TODO() 229 | | fun T.layoutBy(addToViewGroup: Boolean, spec: T.() -> LayoutSpec): T = TODO() 230 | | fun T.applyLayout(x: XAxisSolver, y: YAxisSolver): T = TODO() 231 | |} 232 | """.trimMargin() 233 | ) 234 | private val VIEW_STUB = java( 235 | """ 236 | |package android.view; 237 | | 238 | |import android.content.Context; 239 | | 240 | |public class View { 241 | | public View(Context context) {} 242 | |} 243 | """.trimMargin() 244 | ) 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /contour/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'org.jetbrains.kotlin.android' 3 | apply plugin: 'com.vanniktech.maven.publish' 4 | 5 | android { 6 | compileSdkVersion versions.targetSdk 7 | 8 | defaultConfig { 9 | minSdkVersion versions.minSdk 10 | } 11 | 12 | testOptions { 13 | unitTests { 14 | includeAndroidResources = true 15 | } 16 | } 17 | } 18 | 19 | dependencies { 20 | testImplementation deps.junit 21 | testImplementation deps.truth 22 | testImplementation deps.robolectric 23 | 24 | lintPublish project(':contour-lint') 25 | } -------------------------------------------------------------------------------- /contour/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=contour 2 | POM_NAME=Contour 3 | POM_PACKAGING=aar 4 | -------------------------------------------------------------------------------- /contour/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/Api.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @file:Suppress("unused") 18 | 19 | package com.squareup.contour 20 | 21 | import com.squareup.contour.SizeMode.Exact 22 | import com.squareup.contour.solvers.XAxisSolver 23 | import com.squareup.contour.solvers.YAxisSolver 24 | 25 | interface LayoutContainer { 26 | val parent: Geometry 27 | } 28 | 29 | interface WidthOfOnlyContext : XAxisSolver, HasXPositionWithoutWidth 30 | interface HeightOfOnlyContext : YAxisSolver, HasYPositionWithoutHeight 31 | 32 | interface HasXPositionWithoutWidth { 33 | fun widthOf( 34 | mode: SizeMode = Exact, 35 | provider: LayoutContainer.() -> XInt 36 | ): XAxisSolver 37 | 38 | fun widthOfFloat( 39 | mode: SizeMode = Exact, 40 | provider: LayoutContainer.() -> XFloat 41 | ): XAxisSolver 42 | } 43 | 44 | interface HasYPositionWithoutHeight { 45 | fun heightOf( 46 | mode: SizeMode = Exact, 47 | provider: LayoutContainer.() -> YInt 48 | ): YAxisSolver 49 | 50 | fun heightOfFloat( 51 | mode: SizeMode = Exact, 52 | provider: LayoutContainer.() -> YFloat 53 | ): YAxisSolver 54 | } 55 | 56 | interface HasLeft : XAxisSolver, HasXPositionWithoutWidth { 57 | fun rightTo( 58 | mode: SizeMode = Exact, 59 | provider: LayoutContainer.() -> XInt 60 | ): XAxisSolver 61 | 62 | fun rightToFloat( 63 | mode: SizeMode = Exact, 64 | provider: LayoutContainer.() -> XFloat 65 | ): XAxisSolver 66 | } 67 | 68 | interface HasRight : XAxisSolver, HasXPositionWithoutWidth { 69 | fun leftTo( 70 | mode: SizeMode = Exact, 71 | provider: LayoutContainer.() -> XInt 72 | ): XAxisSolver 73 | 74 | fun leftToFloat( 75 | mode: SizeMode = Exact, 76 | provider: LayoutContainer.() -> XFloat 77 | ): XAxisSolver 78 | } 79 | 80 | interface HasTop : YAxisSolver, HasYPositionWithoutHeight { 81 | fun bottomTo( 82 | mode: SizeMode = Exact, 83 | provider: LayoutContainer.() -> YInt 84 | ): YAxisSolver 85 | 86 | fun bottomToFloat( 87 | mode: SizeMode = Exact, 88 | provider: LayoutContainer.() -> YFloat 89 | ): YAxisSolver 90 | } 91 | 92 | interface HasBottom : YAxisSolver, HasYPositionWithoutHeight { 93 | fun topTo( 94 | mode: SizeMode = Exact, 95 | provider: LayoutContainer.() -> YInt 96 | ): YAxisSolver 97 | 98 | fun topToFloat( 99 | mode: SizeMode = Exact, 100 | provider: LayoutContainer.() -> YFloat 101 | ): YAxisSolver 102 | } 103 | -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/ContourLayout.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @file:Suppress("unused", "NOTHING_TO_INLINE", "MemberVisibilityCanBePrivate") 18 | 19 | package com.squareup.contour 20 | 21 | import android.content.Context 22 | import android.graphics.Rect 23 | import android.util.AttributeSet 24 | import android.view.View 25 | import android.view.ViewGroup 26 | import com.squareup.contour.constraints.SizeConfig 27 | import com.squareup.contour.constraints.SizeConfigSmartLambdas.CoordinateAxis.HORIZONTAL 28 | import com.squareup.contour.constraints.SizeConfigSmartLambdas.CoordinateAxis.VERTICAL 29 | import com.squareup.contour.constraints.SizeConfigSmartLambdas.matchParent 30 | import com.squareup.contour.constraints.SizeConfigSmartLambdas.wrapContent 31 | import com.squareup.contour.errors.CircularReferenceDetected 32 | import com.squareup.contour.solvers.AxisSolver 33 | import com.squareup.contour.solvers.ComparisonResolver 34 | import com.squareup.contour.solvers.ComparisonResolver.CompareBy.MaxOf 35 | import com.squareup.contour.solvers.ComparisonResolver.CompareBy.MinOf 36 | import com.squareup.contour.solvers.SimpleAxisSolver 37 | import com.squareup.contour.solvers.SimpleAxisSolver.Point.Baseline 38 | import com.squareup.contour.solvers.SimpleAxisSolver.Point.Max 39 | import com.squareup.contour.solvers.SimpleAxisSolver.Point.Mid 40 | import com.squareup.contour.solvers.SimpleAxisSolver.Point.Min 41 | import com.squareup.contour.solvers.XAxisSolver 42 | import com.squareup.contour.solvers.YAxisSolver 43 | import com.squareup.contour.utils.toXInt 44 | import com.squareup.contour.utils.toYInt 45 | import com.squareup.contour.utils.unwrapXIntLambda 46 | import com.squareup.contour.utils.unwrapXIntToXIntLambda 47 | import com.squareup.contour.utils.unwrapYIntLambda 48 | import com.squareup.contour.utils.unwrapYIntToYIntLambda 49 | import com.squareup.contour.wrappers.HasDimensions 50 | import com.squareup.contour.wrappers.ParentGeometry 51 | import com.squareup.contour.wrappers.ViewDimensions 52 | import kotlin.math.max 53 | import kotlin.math.min 54 | 55 | private const val WRAP = ViewGroup.LayoutParams.WRAP_CONTENT 56 | 57 | /** 58 | * The central class to use when interacting with Contour. 59 | * 60 | * To build a custom layout inherit from [ContourLayout]. This will: 61 | * a) Expose a set of functions & extension functions - scoped to your layout - that will allow you to define 62 | * your layout. 63 | * b) Provide the layout mechanism to drive your layout. 64 | * 65 | * Views ~can~ be defined in XML layouts and configured with [ContourLayout] but this is not really recommended. 66 | * The intended use case for inheritors of [ContourLayout] is to define the children views programmatically in Kotlin 67 | * using [apply] block's to declare the views content and style. You can see examples of this in 68 | * [sample-app/src/main/java/com/squareup/contour/sample] but here is an example of defining a simple [TextView]: 69 | * 70 | * private val starDate = TextView(context).apply { 71 | * text = "Stardate: 23634.1" 72 | * setTextColor(White) 73 | * setTextSize(TypedValue.COMPLEX_UNIT_DIP, 18f) 74 | * } 75 | * 76 | * This will instantiate and configure your child view but does not add it to your layout. To add a view to the 77 | * [ContourLayout] use the extension function [View.layoutBy] which is defined in the scope of [ContourLayout]. 78 | * [View.layoutBy] does two things. It adds the child view to your layout - if not already added - and configures 79 | * the layout of the view. The configuration happens via the arguments provided to [View.layoutBy] which are an 80 | * instance of a [XAxisSolver] and a [YAxisSolver]. [XAxisSolver] and [YAxisSolver] are symmetrical interfaces which tell 81 | * Contour how to layout the child view on its x and y axes. 82 | * 83 | * To start defining [XAxisSolver] / [YAxisSolver] use any of the position declaration functions defined in the 84 | * [ContourLayout] scope. Eg: [leftTo], [rightTo], [centerHorizontallyTo], [topTo], etc. These functions will 85 | * return a [XAxisSolver]/[YAxisSolver] with the minimum configuration to show your view on screen. 86 | * 87 | * In the example above the view [starDate] could be configured with the code: 88 | * 89 | * starDate.layoutBy( 90 | * x = leftTo { parent.left() }, 91 | * y = topTo { parent.top() } 92 | * ) 93 | * 94 | * This would layout your view at the top-left corner of your layout with child views default desired size. In the case 95 | * of a [TextView] this would be to wrap it's text, in the case of an [ImageView] this would be the image resources 96 | * implicit size, in the case of the base class [View] this would be 0 width and height. 97 | * 98 | * In the example above there a couple things to note. First the reference to [parent] of made available within all 99 | * [XAxisSolver] / [YAxisSolver] scopes. [parent] represents the parent geometry and is guaranteed to be resolved when 100 | * any of its methods are called. The values returned by [parent] will be in the layuot's coordinate space, where 101 | * 0,0 is top-left and the layout width, height will be bottom-right. 102 | * 103 | * An additional thing to note is all the [parent] methods return one of two types: [XInt] and [YInt]. 104 | * [XInt] and [YInt] represent a resolved layout value on it's corresponding axis, what this provides us is axis level 105 | * type-safety. For example: 106 | * 107 | * starDate.layoutBy( 108 | * x = leftTo { parent.top() }, 109 | * y = topTo { parent.top() } 110 | * ) 111 | * 112 | * Will not compile. [leftTo] requires an [XInt] and [top()] returns a [YInt]. The intention of this decision is to 113 | * a) avoid accidentally configure against an point on the wrong axis. 114 | * b) avoid accidentally referencing the virtual methods on [View] - [View.getLeft], [View.getTop], etc. 115 | * The methods [View.getLeft], [View.getTop], etc. still work and return [Int] values in the context of contour, but 116 | * do not provide the guarantee that the corresponding contour methods [View.left], [View.top], etc provide - which is 117 | * they will always be laid out and valid by the time the function returns. 118 | * 119 | * [XInt] and [YInt] are implemented as inline classes which means performance should be no different from using 120 | * regular [Int]s - and infact will compile down to native java [Int] primitives in most cases. 121 | * More on inline classes: https://kotlinlang.org/docs/reference/inline-classes.html 122 | * 123 | * In addition to siding [XAxisSolver] and [YAxisSolver] can define the width / height of the layout. In the example 124 | * above we can hard-code a height and set a max width with: 125 | * 126 | * starDate.layoutBy( 127 | * x = leftTo { parent.left() } 128 | * .rightTo(AtMost) { parent.right() }, 129 | * y = topTo { parent.top() } 130 | * .heightOf { 50.ydip } 131 | * ) 132 | * 133 | * In the above example width is implicitly determined by the leftTo, rightTo directives and by including the AtMost 134 | * argument, will dynamically size the width (starDate will be as wide as the text dictates - up until the right edge 135 | * reaches the parent.right() - when it will ellipsize). heightOf is simply hardcoded with heightOf { 50.ydip }. 136 | * Something of note here is the scoped extension function [ydip] and the corresponding [xdip]. These unsurprisingly 137 | * return Android DIP values as [XInt]/[YInt]. 138 | * 139 | * You can also use an scoped extension function plain-old [dip], which cannot be used directly with Resolvers 140 | * ( widthOf { 10.dip } will not compile Int != YInt), but when combined with other functions doesn't require the 141 | * explicitness of [xdip]/[ydip] eg: ( widthOf { parent.width() - 10.dip } *will* compile. (XInt + Int) == XInt) 142 | * 143 | * Finally, you can reference not only your parent in contour, but any sibling in the layout. So instead of aligning 144 | * your left side with the [ContourLayout], you could instead reference another view, we'll call avatar: 145 | * 146 | * starDate.layoutBy( 147 | * x = leftTo { avatar.right() }, 148 | * y = centerVerticallyTo { avatar.centerY() } 149 | * ) 150 | * 151 | * Where you call [layoutBy] is up to you. There are two available styles. Either calling directly in your child 152 | * views [apply] block, or alternatively in your layout's [init] block. One thing to note about [layoutBy] is it 153 | * will add the view to the [ContourLayout] if not already added. This dictates the draw order - first added 154 | * will be drawn underneath everything else. 155 | */ 156 | open class ContourLayout( 157 | context: Context, 158 | attrs: AttributeSet? 159 | ) : ViewGroup(context, attrs) { 160 | 161 | // Using a secondary constructor instead of @JvmOverloads ensures that 162 | // it shows up in autocomplete suggestions when extending ContourLayout. 163 | constructor(context: Context): this(context, null) 164 | 165 | /** 166 | * Whether Contour should respect padding set on this layout as part of laying out its subviews. 167 | * When set to `true`, padding will influence the alignment of subviews to the layout's inner edges 168 | * When set to `false`, padding will be ignored in all layout calculations. 169 | * 170 | * This flag exists for the sole purpose of facilitating baby-steps migration from alpha versions 171 | * of Contour where padding was not taken into account, allowing devs to migrate one view at a time. 172 | * 173 | * @see ViewGroup.setPadding 174 | */ 175 | @Deprecated("Migrate implementation to properly take padding into account or explicitly null it out") 176 | var respectPadding: Boolean = true 177 | 178 | private val density: Float = context.resources.displayMetrics.density 179 | private val widthConfig = SizeConfig(lambda = matchParent()) 180 | private val heightConfig = SizeConfig(lambda = matchParent()) 181 | private val geometry = ParentGeometry( 182 | widthConfig = widthConfig, 183 | heightConfig = heightConfig, 184 | paddingConfig = { 185 | if (respectPadding) { 186 | Rect(paddingLeft, paddingTop, paddingRight, paddingBottom) 187 | } else { 188 | Rect(0, 0, 0, 0) 189 | } 190 | } 191 | ) 192 | private var constructed: Boolean = true 193 | private var lastWidthSpec: Int = 0 194 | private var lastHeightSpec: Int = 0 195 | 196 | override fun requestLayout() { 197 | if (constructed) { 198 | invalidateAll() 199 | } 200 | super.requestLayout() 201 | } 202 | 203 | override fun onMeasure( 204 | widthMeasureSpec: Int, 205 | heightMeasureSpec: Int 206 | ) { 207 | if (lastWidthSpec != widthMeasureSpec || lastHeightSpec != heightMeasureSpec) { 208 | invalidateAll() 209 | } 210 | 211 | widthConfig.available = MeasureSpec.getSize(widthMeasureSpec) 212 | heightConfig.available = MeasureSpec.getSize(heightMeasureSpec) 213 | setMeasuredDimension(widthConfig.resolve(), heightConfig.resolve()) 214 | 215 | lastWidthSpec = widthMeasureSpec 216 | lastHeightSpec = heightMeasureSpec 217 | } 218 | 219 | override fun onLayout( 220 | changed: Boolean, 221 | l: Int, 222 | t: Int, 223 | r: Int, 224 | b: Int 225 | ) { 226 | for (i in 0 until childCount) { 227 | val child = getChildAt(i) 228 | if (child.visibility == GONE) { 229 | continue 230 | } 231 | 232 | val params = child.spec() 233 | params.measureSelf() 234 | child.layout( 235 | params.left().value, params.top().value, 236 | params.right().value, params.bottom().value 237 | ) 238 | } 239 | } 240 | 241 | private fun invalidateAll() { 242 | widthConfig.clear() 243 | heightConfig.clear() 244 | for (i in 0 until childCount) { 245 | val child = getChildAt(i) 246 | (child.layoutParams as? LayoutSpec)?.clear() 247 | } 248 | } 249 | 250 | private inline fun View.spec(): LayoutSpec { 251 | if (parent !== this@ContourLayout) { 252 | throw IllegalArgumentException("Referencing view outside of ViewGroup.") 253 | } 254 | return layoutParams as LayoutSpec 255 | } 256 | 257 | private inline fun View.handleCrd(block: () -> T): T { 258 | return try { 259 | block() 260 | } catch (e: CircularReferenceDetected) { 261 | // Want first stacktrace element to be following line. 262 | // Thread.currentThread().stackTrace does not do this. 263 | val trace = Throwable().stackTrace 264 | val current = trace.getOrNull(0) 265 | val calledBy = trace.getOrNull(1) 266 | e.add(CircularReferenceDetected.TraceElement(this, current, calledBy)) 267 | throw e 268 | } 269 | } 270 | 271 | // API 272 | 273 | val Int.dip: Int get() = (density * this).toInt() 274 | val Int.xdip: XInt get() = XInt((density * this).toInt()) 275 | val Int.ydip: YInt get() = YInt((density * this).toInt()) 276 | 277 | val Float.dip: Float get() = density * this 278 | val Float.xdip: XFloat get() = XFloat(density * this) 279 | val Float.ydip: YFloat get() = YFloat(density * this) 280 | 281 | inline fun Int.toXInt(): XInt = XInt(this) 282 | inline fun Int.toYInt(): YInt = YInt(this) 283 | 284 | @Deprecated( 285 | message = "Views should be configured using layoutBy{} instead.", 286 | replaceWith = ReplaceWith("layoutBy(addToViewGroup, config)") 287 | ) 288 | fun T.contourOf( 289 | addToViewGroup: Boolean = true, 290 | config: T.() -> LayoutSpec 291 | ): T { 292 | val viewGroup = this@ContourLayout 293 | val spec = config() 294 | spec.dimen = ViewDimensions(this) 295 | spec.parent = viewGroup.geometry 296 | layoutParams = spec 297 | if (addToViewGroup) { 298 | viewGroup.addViewInternal(this) 299 | } 300 | return this 301 | } 302 | 303 | /** 304 | * Overrides how the [ContourLayout] should size its width by reverting to the default behaviour, which 305 | * is to take all the available space it is given 306 | */ 307 | fun contourWidthMatchParent() { 308 | widthConfig.lambda = matchParent() 309 | } 310 | 311 | /** 312 | * Overrides how the [ContourLayout] should size its width. By default [ContourLayout] will take all the available 313 | * space it is given. This override allows fine grained control over the resulting final measure. 314 | * @param config a function that takes a [XInt] - which is the available space supplied by the [ContourLayout]'s 315 | * parent - and returns a [XInt] describing how wide the [ContourLayout] should be. 316 | * 317 | * Note: It is acceptable to reference the children views in the [config] so long as circular references are not 318 | * introduced! 319 | */ 320 | fun contourWidthOf(config: (available: XInt) -> XInt) { 321 | widthConfig.lambda = unwrapXIntToXIntLambda(config) 322 | } 323 | 324 | /** 325 | * Overrides how the [ContourLayout] should size its width. This override instructs the view to take up 326 | * as much space as necessary to wrap its subviews, including its own padding. 327 | */ 328 | fun contourWidthWrapContent() { 329 | widthConfig.lambda = wrapContent(this, HORIZONTAL) 330 | } 331 | 332 | /** 333 | * Overrides how the [ContourLayout] should size its height by reverting to the default behaviour, which 334 | * is to take all the available space it is given 335 | */ 336 | fun contourHeightMatchParent() { 337 | heightConfig.lambda = matchParent() 338 | } 339 | 340 | /** 341 | * Overrides how the [ContourLayout] should size its height. By default [ContourLayout] will take all the available 342 | * space it is given. This override allows fine grained control over the resulting final measure. 343 | * @param config a function that takes a [YInt] - which is the available space supplied by the [ContourLayout]'s 344 | * parent - and returns a [YInt] describing how tall the [ContourLayout] should be. 345 | * 346 | * Note: It is acceptable to reference the children views in the [config] so long as circular references are not 347 | * introduced! 348 | */ 349 | fun contourHeightOf(config: (available: YInt) -> YInt) { 350 | heightConfig.lambda = unwrapYIntToYIntLambda(config) 351 | } 352 | 353 | /** 354 | * Overrides how the [ContourLayout] should size its height. This override instructs the view to take up 355 | * as much space as necessary to wrap its subviews, including its own padding. 356 | */ 357 | fun contourHeightWrapContent() { 358 | heightConfig.lambda = wrapContent(this, VERTICAL) 359 | } 360 | 361 | /** 362 | * Describes a View's position and dimensions according to the [x] and [y] layout specs. 363 | * If needed, the layout spec can later be modified using [updateLayoutBy]. 364 | * 365 | * Usage: 366 | * 367 | * ``` 368 | * init { 369 | * starDate.layoutBy( 370 | * x = leftTo { parent.left() }, 371 | * y = topTo { parent.top() } 372 | * ) 373 | * } 374 | * ``` 375 | * 376 | * @param addToViewGroup if true, this View will be add to the [ContourLayout] if it 377 | * is not already added. 378 | */ 379 | fun T.layoutBy( 380 | x: XAxisSolver, 381 | y: YAxisSolver, 382 | addToViewGroup: Boolean = true 383 | ): T { 384 | val viewGroup = this@ContourLayout 385 | layoutParams = LayoutSpec(x, y).also { 386 | it.dimen = ViewDimensions(this) 387 | it.parent = viewGroup.geometry 388 | it.view = this 389 | } 390 | if (addToViewGroup && parent == null) { 391 | viewGroup.addViewInternal(this) 392 | } 393 | return this 394 | } 395 | 396 | @Deprecated( 397 | message = "Views should be configured using layoutBy() instead.", 398 | replaceWith = ReplaceWith("layoutBy(x, y, addToViewGroup)") 399 | ) 400 | fun T.applyLayout( 401 | x: XAxisSolver, 402 | y: YAxisSolver, 403 | addToViewGroup: Boolean = true 404 | ) { 405 | layoutBy(x, y, addToViewGroup) 406 | } 407 | 408 | @Deprecated(message = "Use layoutBy(x = emptyX(), y = emptyY()) instead.") 409 | @Suppress("DeprecatedCallableAddReplaceWith") // breaks code 410 | fun View.applyEmptyLayout() = 411 | layoutBy(x = emptyX(), y = emptyY()) 412 | 413 | /** 414 | * Updates the layout configuration of receiver view with new optional [XAxisSolver] and/or [YAxisSolver] 415 | * @receiver the view to configure 416 | * @param x configures how the [View] will be positioned and sized on the x-axis. 417 | * @param y configures how the [View] will be positioned and sized on the y-axis. 418 | */ 419 | fun View.updateLayoutBy( 420 | x: XAxisSolver = spec().x, 421 | y: YAxisSolver = spec().y 422 | ) { 423 | val viewGroup = this@ContourLayout 424 | val spec = LayoutSpec(x, y) 425 | spec.dimen = ViewDimensions(this) 426 | spec.parent = viewGroup.geometry 427 | spec.view = this 428 | layoutParams = spec 429 | } 430 | 431 | @Deprecated("Use updateLayoutBy", ReplaceWith("updateLayoutBy(x, y)")) 432 | fun View.updateLayout( 433 | x: XAxisSolver = spec().x, 434 | y: YAxisSolver = spec().y 435 | ) { 436 | updateLayoutBy(x, y) 437 | } 438 | 439 | @Deprecated("Use updateLayoutBy", ReplaceWith("updateLayoutBy(x, y)")) 440 | fun View.updateLayoutSpec( 441 | x: XAxisSolver = spec().x, 442 | y: YAxisSolver = spec().y 443 | ) { 444 | updateLayoutBy(x, y) 445 | } 446 | 447 | /** 448 | * The left position of the receiver [View]. Guaranteed to return the resolved value or throw. 449 | * @return the laid-out left position of the [View] 450 | */ 451 | fun View.left(): XInt = handleCrd { spec().left() } 452 | 453 | /** 454 | * The top position of the receiver [View]. Guaranteed to return the resolved value or throw. 455 | * @return the laid-out top position of the [View] 456 | */ 457 | fun View.top(): YInt = handleCrd { spec().top() } 458 | 459 | /** 460 | * The right position of the receiver [View]. Guaranteed to return the resolved value or throw. 461 | * @return the laid-out right position of the [View] 462 | */ 463 | fun View.right(): XInt = handleCrd { spec().right() } 464 | 465 | /** 466 | * The bottom position of the receiver [View]. Guaranteed to return the resolved value or throw. 467 | * @return the laid-out bottom position of the [View] 468 | */ 469 | fun View.bottom(): YInt = handleCrd { spec().bottom() } 470 | 471 | /** 472 | * The center-x position of the receiver [View]. Guaranteed to return the resolved value or throw. 473 | * @return the laid-out left center-x of the [View] 474 | */ 475 | fun View.centerX(): XInt = handleCrd { spec().centerX() } 476 | 477 | /** 478 | * The center-y position of the receiver [View]. Guaranteed to return the resolved value or throw. 479 | * @return the laid-out center-y position of the [View] 480 | */ 481 | fun View.centerY(): YInt = handleCrd { spec().centerY() } 482 | 483 | /** 484 | * The baseline position of the receiver [View]. Guaranteed to return the resolved value or throw. 485 | * @return the laid-out baseline position of the [View] 486 | * 487 | * The baseline position will be 0 if the receiver [View] does not have a baseline. The most notable use of baseline 488 | * is in [TextView] which provides the baseline of the text. 489 | */ 490 | fun View.baseline(): YInt = handleCrd { spec().baseline() } 491 | 492 | /** 493 | * The width of the receiver [View]. Guaranteed to return the resolved value or throw. 494 | * @return the laid-out width of the [View] 495 | */ 496 | fun View.width(): XInt = handleCrd { spec().width() } 497 | 498 | /** 499 | * The height of the receiver [View]. Guaranteed to return the resolved value or throw. 500 | * @return the laid-out height of the [View] 501 | */ 502 | fun View.height(): YInt = handleCrd { spec().height() } 503 | 504 | /** 505 | * The preferred width of the receiver [View] when no constraints are applied to the view. 506 | * @return the preferred width of the [View] 507 | */ 508 | fun View.preferredWidth(): XInt = handleCrd { spec().preferredWidth() } 509 | 510 | /** 511 | * The preferred height of the receiver [View] when no constraints are applied to the view. 512 | * @return the preferred height of the [View] 513 | */ 514 | fun View.preferredHeight(): YInt = handleCrd { spec().preferredHeight() } 515 | 516 | fun baselineTo(provider: LayoutContainer.() -> YInt): HeightOfOnlyContext = 517 | SimpleAxisSolver( 518 | point = Baseline, 519 | lambda = unwrapYIntLambda(provider) 520 | ) 521 | 522 | fun topTo(provider: LayoutContainer.() -> YInt): HasTop = 523 | SimpleAxisSolver( 524 | point = Min, 525 | lambda = unwrapYIntLambda(provider) 526 | ) 527 | 528 | fun bottomTo(provider: LayoutContainer.() -> YInt): HasBottom = 529 | SimpleAxisSolver( 530 | point = Max, 531 | lambda = unwrapYIntLambda(provider) 532 | ) 533 | 534 | fun centerVerticallyTo(provider: LayoutContainer.() -> YInt): HeightOfOnlyContext = 535 | SimpleAxisSolver( 536 | point = Mid, 537 | lambda = unwrapYIntLambda(provider) 538 | ) 539 | 540 | fun leftTo(provider: LayoutContainer.() -> XInt): HasLeft = 541 | SimpleAxisSolver( 542 | point = Min, 543 | lambda = unwrapXIntLambda(provider) 544 | ) 545 | 546 | fun rightTo(provider: LayoutContainer.() -> XInt): HasRight = 547 | SimpleAxisSolver( 548 | point = Max, 549 | lambda = unwrapXIntLambda(provider) 550 | ) 551 | 552 | fun centerHorizontallyTo(provider: LayoutContainer.() -> XInt): WidthOfOnlyContext = 553 | SimpleAxisSolver( 554 | point = Mid, 555 | lambda = unwrapXIntLambda(provider) 556 | ) 557 | 558 | /** 559 | * Matches the position and width to those of [View] on the x-axis. 560 | * @param view sibling [View] to match to. 561 | * @param marginLeft extra space on the left. 562 | * @param marginRight extra space on the right. 563 | * @return the configured position on the x-axis 564 | */ 565 | fun matchXTo( 566 | view: View, 567 | marginLeft: Int = 0, 568 | marginRight: Int = 0 569 | ): XAxisSolver { 570 | return leftTo { view.left() + marginLeft }.rightTo { view.right() - marginRight } 571 | } 572 | 573 | /** 574 | * Matches the position and width to those of the parent view on the x-axis. 575 | * @param marginLeft extra space on the left. 576 | * @param marginRight extra space on the right. 577 | * @return the configured position on the x-axis 578 | */ 579 | fun matchParentX( 580 | marginLeft: Int = 0, 581 | marginRight: Int = 0 582 | ): XAxisSolver { 583 | return leftTo { parent.left() + marginLeft }.rightTo { parent.right() - marginRight } 584 | } 585 | 586 | /** 587 | * Matches the position and height to those of [View] on the y-axis. 588 | * @param view sibling [View] to match to. 589 | * @param marginTop extra space on the top. 590 | * @param marginBottom extra space on the bottom. 591 | * @return the configured position on the y-axis 592 | */ 593 | fun matchYTo( 594 | view: View, 595 | marginTop: Int = 0, 596 | marginBottom: Int = 0 597 | ): YAxisSolver { 598 | return topTo { view.top() + marginTop }.bottomTo { view.bottom() - marginBottom } 599 | } 600 | 601 | /** 602 | * Matches the position and height to those of the parent view on the y-axis. 603 | * @param marginTop extra space on the top. 604 | * @param marginBottom extra space on the bottom. 605 | * @return the configured position on the y-axis 606 | */ 607 | fun matchParentY( 608 | marginTop: Int = 0, 609 | marginBottom: Int = 0 610 | ): YAxisSolver { 611 | return topTo { parent.top() + marginTop }.bottomTo { parent.bottom() - marginBottom } 612 | } 613 | 614 | /** 615 | * Assigns zero-width to the view. 616 | */ 617 | fun emptyX() = leftTo { parent.left() }.widthOf { 0.toXInt() } 618 | 619 | /** 620 | * Assigns zero-height to the view. 621 | */ 622 | fun emptyY() = topTo { parent.top() }.heightOf { 0.toYInt() } 623 | 624 | fun minOf( 625 | a: XInt, 626 | b: XInt 627 | ): XInt = min(a.value, b.value).toXInt() 628 | 629 | fun minOf( 630 | a: YInt, 631 | b: YInt 632 | ): YInt = min(a.value, b.value).toYInt() 633 | 634 | fun maxOf( 635 | a: XInt, 636 | b: XInt 637 | ): XInt = max(a.value, b.value).toXInt() 638 | 639 | fun maxOf( 640 | a: YInt, 641 | b: YInt 642 | ): YInt = max(a.value, b.value).toYInt() 643 | 644 | fun minOf( 645 | p0: HasYPositionWithoutHeight, 646 | p1: HasYPositionWithoutHeight 647 | ): YAxisSolver { 648 | p0 as AxisSolver 649 | p1 as AxisSolver 650 | return ComparisonResolver(p0, p1, MinOf) 651 | } 652 | 653 | fun maxOf( 654 | p0: HasYPositionWithoutHeight, 655 | p1: HasYPositionWithoutHeight 656 | ): YAxisSolver { 657 | p0 as AxisSolver 658 | p1 as AxisSolver 659 | return ComparisonResolver(p0, p1, MaxOf) 660 | } 661 | 662 | fun minOf( 663 | p0: HasXPositionWithoutWidth, 664 | p1: HasXPositionWithoutWidth 665 | ): XAxisSolver { 666 | p0 as AxisSolver 667 | p1 as AxisSolver 668 | return ComparisonResolver(p0, p1, MinOf) 669 | } 670 | 671 | fun maxOf( 672 | p0: HasXPositionWithoutWidth, 673 | p1: HasXPositionWithoutWidth 674 | ): XAxisSolver { 675 | p0 as AxisSolver 676 | p1 as AxisSolver 677 | return ComparisonResolver(p0, p1, MaxOf) 678 | } 679 | 680 | private fun addViewInternal(child: View?) = super.addView(child) 681 | 682 | @Deprecated(ADDVIEW_DEPRECATION_MESSAGE, ReplaceWith(ADDVIEW_DEPRECATION_QUICKFIX)) 683 | override fun addView(child: View) = super.addView(child) 684 | 685 | @Deprecated(ADDVIEW_DEPRECATION_MESSAGE, ReplaceWith(ADDVIEW_DEPRECATION_QUICKFIX)) 686 | override fun addView(child: View, index: Int) = super.addView(child, index) 687 | 688 | @Deprecated(ADDVIEW_DEPRECATION_MESSAGE, ReplaceWith(ADDVIEW_DEPRECATION_QUICKFIX)) 689 | override fun addView(child: View, params: LayoutParams) = super.addView(child, params) 690 | 691 | @Deprecated(ADDVIEW_DEPRECATION_MESSAGE, ReplaceWith(ADDVIEW_DEPRECATION_QUICKFIX)) 692 | override fun addView(child: View, index: Int, params: LayoutParams) = 693 | super.addView(child, index, params) 694 | 695 | @Deprecated(ADDVIEW_DEPRECATION_MESSAGE, ReplaceWith(ADDVIEW_DEPRECATION_QUICKFIX)) 696 | override fun addView(child: View, width: Int, height: Int) = 697 | super.addView(child, width, height) 698 | 699 | @Deprecated(ADDVIEW_DEPRECATION_MESSAGE, ReplaceWith(ADDVIEW_DEPRECATION_QUICKFIX)) 700 | override fun addViewInLayout(child: View, index: Int, params: LayoutParams) = 701 | super.addViewInLayout(child, index, params) 702 | 703 | @Deprecated(ADDVIEW_DEPRECATION_MESSAGE, ReplaceWith(ADDVIEW_DEPRECATION_QUICKFIX)) 704 | override fun addViewInLayout( 705 | child: View, index: Int, params: LayoutParams, preventRequestLayout: Boolean 706 | ) = if (child.layoutParams is LayoutSpec) 707 | super.addViewInLayout(child, index, params, preventRequestLayout) 708 | else 709 | throw UnsupportedOperationException(ADDVIEW_DEPRECATION_MESSAGE) 710 | 711 | class LayoutSpec( 712 | internal val x: XAxisSolver, 713 | internal val y: YAxisSolver 714 | ) : ViewGroup.LayoutParams(WRAP, WRAP), LayoutContainer { 715 | 716 | override lateinit var parent: Geometry 717 | internal lateinit var dimen: HasDimensions 718 | internal lateinit var view: View 719 | 720 | init { 721 | x.onAttach(this) 722 | y.onAttach(this) 723 | } 724 | 725 | internal fun left(): XInt = x.min().toXInt() 726 | internal fun right(): XInt = x.max().toXInt() 727 | internal fun centerX(): XInt = x.mid().toXInt() 728 | internal fun top(): YInt = y.min().toYInt() 729 | internal fun bottom(): YInt = y.max().toYInt() 730 | internal fun centerY(): YInt = y.mid().toYInt() 731 | internal fun baseline(): YInt = y.baseline().toYInt() 732 | internal fun width(): XInt = x.range().toXInt() 733 | internal fun height(): YInt = y.range().toYInt() 734 | 735 | internal fun preferredWidth(): XInt { 736 | return if (view.visibility == GONE) { 737 | XInt.ZERO 738 | } else { 739 | dimen.measure(0, y.measureSpec()) 740 | dimen.width.toXInt() 741 | } 742 | } 743 | 744 | internal fun preferredHeight(): YInt { 745 | return if (view.visibility == GONE) { 746 | YInt.ZERO 747 | } else { 748 | dimen.measure(x.measureSpec(), 0) 749 | dimen.height.toYInt() 750 | } 751 | } 752 | 753 | internal fun measureSelf() { 754 | if (view.visibility == GONE) { 755 | x.onRangeResolved(0, 0) 756 | y.onRangeResolved(0, 0) 757 | } else { 758 | dimen.measure(x.measureSpec(), y.measureSpec()) 759 | x.onRangeResolved(dimen.width, 0) 760 | y.onRangeResolved(dimen.height, dimen.baseline) 761 | } 762 | } 763 | 764 | internal fun clear() { 765 | x.clear() 766 | y.clear() 767 | } 768 | } 769 | 770 | companion object { 771 | const val ADDVIEW_DEPRECATION_MESSAGE = "Incorrectly adding view to ContourLayout" 772 | const val ADDVIEW_DEPRECATION_QUICKFIX = "child.layoutBy(x = matchParentX(), y = matchParentY())" 773 | } 774 | } 775 | -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/Geometry.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @file:Suppress("unused") 18 | 19 | package com.squareup.contour 20 | 21 | import android.graphics.Rect 22 | 23 | /** 24 | * Represents a rectangle in your layout. 25 | * The methods obey a contract that they will either return the correct laid out or throw a 26 | * [com.squareup.contour.errors.CircularReferenceDetected] if the [com.squareup.contour.ContourLayout] 27 | * is configured incorrectly. 28 | */ 29 | interface Geometry { 30 | fun left(): XInt 31 | fun right(): XInt 32 | fun width(): XInt 33 | fun centerX(): XInt 34 | 35 | fun top(): YInt 36 | fun bottom(): YInt 37 | fun height(): YInt 38 | fun centerY(): YInt 39 | 40 | fun padding(): Rect 41 | } 42 | -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/SizeMode.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour 18 | 19 | import android.view.View.MeasureSpec 20 | 21 | /** 22 | * Corresponds with the [MeasureSpec] modes [MeasureSpec.EXACTLY] & [MeasureSpec.AT_MOST]. 23 | * Majority of the time [SizeMode.Exact] is what should be used. A common use-case for [SizeMode.AtMost] is if you 24 | * are laying out text and want the corresponding view to size itself based on the text width, but ellipsize if the 25 | * once the view hits the specified maximum value. 26 | */ 27 | enum class SizeMode(val mask: Int) { 28 | Exact(MeasureSpec.EXACTLY), 29 | AtMost(MeasureSpec.AT_MOST) 30 | } 31 | -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/XFloat.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @file:Suppress("EXPERIMENTAL_FEATURE_WARNING", "NOTHING_TO_INLINE", "unused") 18 | 19 | package com.squareup.contour 20 | 21 | /** 22 | * Represents an [Float] on the x axis. 23 | */ 24 | @JvmInline 25 | value class XFloat(val value: Float) { 26 | 27 | inline operator fun plus(other: Int) = XFloat(value + other) 28 | inline operator fun plus(other: XInt) = XFloat(value + other.value) 29 | inline operator fun plus(other: Float) = XFloat(value + other) 30 | inline operator fun plus(other: XFloat) = XFloat(value + other.value) 31 | 32 | inline operator fun minus(other: Int) = XFloat(value - other) 33 | inline operator fun minus(other: XInt) = XFloat(value - other.value) 34 | inline operator fun minus(other: Float) = XFloat(value - other) 35 | inline operator fun minus(other: XFloat) = XFloat(value - other.value) 36 | 37 | inline operator fun times(other: Int) = XFloat(value * other) 38 | inline operator fun times(other: XInt) = XFloat(value * other.value) 39 | inline operator fun times(other: Float) = XFloat(value * other) 40 | inline operator fun times(other: XFloat) = XFloat(value * other.value) 41 | 42 | inline operator fun div(other: Int) = XFloat(value / other) 43 | inline operator fun div(other: XInt) = XFloat(value / other.value) 44 | inline operator fun div(other: Float) = XFloat(value / other) 45 | inline operator fun div(other: XFloat) = XFloat(value / other.value) 46 | 47 | inline operator fun compareTo(other: Int) = value.compareTo(other) 48 | inline operator fun compareTo(other: XInt) = value.compareTo(other.value) 49 | inline operator fun compareTo(other: Float) = value.compareTo(other) 50 | inline operator fun compareTo(other: XFloat) = value.compareTo(other.value) 51 | 52 | inline fun toY() = YFloat(value) 53 | inline fun toInt() = XInt(value.toInt()) 54 | 55 | companion object { 56 | val ZERO = XFloat(0f) 57 | val MIN_VALUE = XFloat(Float.MIN_VALUE) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/XInt.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @file:Suppress("EXPERIMENTAL_FEATURE_WARNING", "NOTHING_TO_INLINE", "unused") 18 | 19 | package com.squareup.contour 20 | 21 | /** 22 | * Represents an [Int] on the x axis. 23 | */ 24 | @JvmInline 25 | value class XInt(val value: Int) { 26 | 27 | inline operator fun plus(other: Int) = XInt(value + other) 28 | inline operator fun plus(other: XInt) = XInt(value + other.value) 29 | inline operator fun plus(other: Float) = XFloat(value + other) 30 | inline operator fun plus(other: XFloat) = XFloat(value + other.value) 31 | 32 | inline operator fun minus(other: Int) = XInt(value - other) 33 | inline operator fun minus(other: XInt) = XInt(value - other.value) 34 | inline operator fun minus(other: Float) = XFloat(value - other) 35 | inline operator fun minus(other: XFloat) = XFloat(value - other.value) 36 | 37 | inline operator fun times(other: Int) = XInt(value * other) 38 | inline operator fun times(other: XInt) = XInt(value * other.value) 39 | inline operator fun times(other: Float) = XFloat(value * other) 40 | inline operator fun times(other: XFloat) = XFloat(value * other.value) 41 | 42 | inline operator fun div(other: Int) = XInt(value / other) 43 | inline operator fun div(other: XInt) = XInt(value / other.value) 44 | inline operator fun div(other: Float) = XFloat(value / other) 45 | inline operator fun div(other: XFloat) = XFloat(value / other.value) 46 | 47 | inline operator fun compareTo(other: Int) = value.compareTo(other) 48 | inline operator fun compareTo(other: XInt) = value.compareTo(other.value) 49 | inline operator fun compareTo(other: Float) = value.compareTo(other) 50 | inline operator fun compareTo(other: XFloat) = value.compareTo(other.value) 51 | 52 | inline fun toY() = YInt(value) 53 | inline fun toFloat() = XFloat(value.toFloat()) 54 | 55 | companion object { 56 | val ZERO = XInt(0) 57 | val MIN_VALUE = XInt(Int.MIN_VALUE) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/YFloat.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @file:Suppress("EXPERIMENTAL_FEATURE_WARNING", "NOTHING_TO_INLINE", "unused") 18 | 19 | package com.squareup.contour 20 | 21 | /** 22 | * Represents an [Float] on the y axis. 23 | */ 24 | @JvmInline 25 | value class YFloat(val value: Float) { 26 | 27 | inline operator fun plus(other: Int) = YFloat(value + other) 28 | inline operator fun plus(other: YInt) = YFloat(value + other.value) 29 | inline operator fun plus(other: Float) = YFloat(value + other) 30 | inline operator fun plus(other: YFloat) = YFloat(value + other.value) 31 | 32 | inline operator fun minus(other: Int) = YFloat(value - other) 33 | inline operator fun minus(other: YInt) = YFloat(value - other.value) 34 | inline operator fun minus(other: Float) = YFloat(value - other) 35 | inline operator fun minus(other: YFloat) = YFloat(value - other.value) 36 | 37 | inline operator fun times(other: Int) = YFloat(value * other) 38 | inline operator fun times(other: YInt) = YFloat(value * other.value) 39 | inline operator fun times(other: Float) = YFloat(value * other) 40 | inline operator fun times(other: YFloat) = YFloat(value * other.value) 41 | 42 | inline operator fun div(other: Int) = YFloat(value / other) 43 | inline operator fun div(other: YInt) = YFloat(value / other.value) 44 | inline operator fun div(other: Float) = YFloat(value / other) 45 | inline operator fun div(other: YFloat) = YFloat(value / other.value) 46 | 47 | inline operator fun compareTo(other: Int) = value.compareTo(other) 48 | inline operator fun compareTo(other: YInt) = value.compareTo(other.value) 49 | inline operator fun compareTo(other: Float) = value.compareTo(other) 50 | inline operator fun compareTo(other: YFloat) = value.compareTo(other.value) 51 | 52 | inline fun toX() = XFloat(value) 53 | inline fun toInt() = YInt(value.toInt()) 54 | 55 | companion object { 56 | val ZERO = YFloat(0f) 57 | val MIN_VALUE = YFloat(Float.MIN_VALUE) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/YInt.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @file:Suppress("EXPERIMENTAL_FEATURE_WARNING", "NOTHING_TO_INLINE", "unused") 18 | 19 | package com.squareup.contour 20 | 21 | /** 22 | * Represents an [Int] on the y axis. 23 | */ 24 | @JvmInline 25 | value class YInt(val value: Int) { 26 | 27 | inline operator fun plus(other: Int) = YInt(value + other) 28 | inline operator fun plus(other: YInt) = YInt(value + other.value) 29 | inline operator fun plus(other: Float) = YFloat(value + other) 30 | inline operator fun plus(other: YFloat) = YFloat(value + other.value) 31 | 32 | inline operator fun minus(other: Int) = YInt(value - other) 33 | inline operator fun minus(other: YInt) = YInt(value - other.value) 34 | inline operator fun minus(other: Float) = YFloat(value - other) 35 | inline operator fun minus(other: YFloat) = YFloat(value - other.value) 36 | 37 | inline operator fun times(other: Int) = YInt(value * other) 38 | inline operator fun times(other: YInt) = YInt(value * other.value) 39 | inline operator fun times(other: Float) = YFloat(value * other) 40 | inline operator fun times(other: YFloat) = YFloat(value * other.value) 41 | 42 | inline operator fun div(other: Int) = YInt(value / other) 43 | inline operator fun div(other: YInt) = YInt(value / other.value) 44 | inline operator fun div(other: Float) = YFloat(value / other) 45 | inline operator fun div(other: YFloat) = YFloat(value / other.value) 46 | 47 | inline operator fun compareTo(other: Int) = value.compareTo(other) 48 | inline operator fun compareTo(other: YInt) = value.compareTo(other.value) 49 | inline operator fun compareTo(other: Float) = value.compareTo(other) 50 | inline operator fun compareTo(other: YFloat) = value.compareTo(other.value) 51 | 52 | inline fun toX() = XInt(value) 53 | inline fun toFloat() = YFloat(value.toFloat()) 54 | 55 | companion object { 56 | val ZERO = YInt(0) 57 | val MIN_VALUE = YInt(Int.MIN_VALUE) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/constraints/Constraint.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.constraints 18 | 19 | import com.squareup.contour.LayoutContainer 20 | import com.squareup.contour.SizeMode 21 | import com.squareup.contour.SizeMode.Exact 22 | import com.squareup.contour.errors.CircularReferenceDetected 23 | import com.squareup.contour.solvers.SimpleAxisSolver.Point 24 | 25 | internal open class Constraint { 26 | private var isResolving: Boolean = false 27 | private var container: LayoutContainer? = null 28 | private var value: Int = Int.MIN_VALUE 29 | var mode: SizeMode = Exact 30 | var lambda: (LayoutContainer.() -> Int)? = null 31 | 32 | val isSet: Boolean get() = lambda != null 33 | 34 | fun onAttachContext(container: LayoutContainer) { 35 | this.container = container 36 | } 37 | 38 | fun resolve(): Int { 39 | if (value == Int.MIN_VALUE) { 40 | val context = 41 | checkNotNull(container) { "Constraint called before LayoutContainer attached" } 42 | val lambda = checkNotNull(lambda) { "Constraint not set" } 43 | 44 | try { 45 | if (isResolving) throw CircularReferenceDetected() 46 | 47 | isResolving = true 48 | value = lambda(context) 49 | } finally { 50 | isResolving = false 51 | } 52 | } 53 | return value 54 | } 55 | 56 | fun clear() { 57 | value = Int.MIN_VALUE 58 | } 59 | } 60 | 61 | internal class PositionConstraint( 62 | var point: Point = Point.Min, 63 | lambda: (LayoutContainer.() -> Int)? = null 64 | ) : Constraint() { 65 | init { 66 | this.lambda = lambda 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/constraints/SizeConfig.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.constraints 18 | 19 | internal typealias SizeConfigLambda = (available: Int) -> Int 20 | internal class SizeConfig( 21 | var available: Int = Int.MIN_VALUE, 22 | var result: Int = Int.MIN_VALUE, 23 | var lambda: SizeConfigLambda 24 | ) { 25 | 26 | fun resolve(): Int { 27 | if (result == Int.MIN_VALUE) { 28 | require(available != Int.MIN_VALUE) { "Triggering layout before parent geometry available" } 29 | result = lambda(available) 30 | } 31 | return result 32 | } 33 | 34 | fun clear() { 35 | result = Int.MIN_VALUE 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/constraints/SizeConfigSmartLambdas.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.constraints 18 | 19 | import android.view.View 20 | import com.squareup.contour.ContourLayout 21 | import com.squareup.contour.constraints.SizeConfigSmartLambdas.CoordinateAxis.HORIZONTAL 22 | import com.squareup.contour.constraints.SizeConfigSmartLambdas.CoordinateAxis.VERTICAL 23 | import com.squareup.contour.utils.children 24 | import kotlin.math.max 25 | 26 | internal object SizeConfigSmartLambdas { 27 | fun matchParent(): SizeConfigLambda = { it } 28 | fun wrapContent(view: ContourLayout, axis: CoordinateAxis): SizeConfigLambda = { 29 | view.run { 30 | children 31 | .filter { it.visibility != View.GONE } 32 | .map { 33 | when (axis) { 34 | VERTICAL -> max(it.bottom().value, paddingTop) + paddingBottom 35 | HORIZONTAL -> max(it.right().value, paddingLeft) + paddingRight 36 | } 37 | } 38 | .maxOrNull() ?: totalPadding(axis) 39 | } 40 | } 41 | 42 | private fun ContourLayout.totalPadding(axis: CoordinateAxis) = when (axis) { 43 | VERTICAL -> paddingTop + paddingBottom 44 | HORIZONTAL -> paddingLeft + paddingRight 45 | } 46 | 47 | enum class CoordinateAxis { 48 | VERTICAL, 49 | HORIZONTAL 50 | } 51 | } -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/errors/CircularReferenceDetected.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.errors 18 | 19 | import android.view.View 20 | 21 | class CircularReferenceDetected : Exception() { 22 | 23 | class TraceElement( 24 | val view: View, 25 | val seenAt: StackTraceElement?, 26 | val referencedFrom: StackTraceElement? 27 | ) 28 | 29 | private val list = ArrayList() 30 | 31 | fun add(trace: TraceElement) { 32 | list += trace 33 | } 34 | 35 | override val message: String 36 | get() = buildString { 37 | val count = list.size 38 | appendLine() 39 | appendLine() 40 | appendLine("Circular reference detected through the following calls:") 41 | list.forEachIndexed { i, t -> 42 | val bullet = "${count - i}) " 43 | val indent = " ".repeat(bullet.length) 44 | append(bullet).appendLine("Calling ${t.seenAt?.methodName}() on ${t.view} from:") 45 | append(indent).appendLine(t.referencedFrom.toString()) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/solvers/AxisSolver.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.solvers 18 | 19 | import com.squareup.contour.ContourLayout.LayoutSpec 20 | 21 | /** 22 | * [AxisSolver] represents a strategy for solving points on an axis. 23 | * Implementations of this interface should be as lazy as possible. Invokers of this interface 24 | * use the least-knowledge fields as possible. This helps solve certain types of layouts where axis dimension and 25 | * position need to be calculated separately. 26 | * 27 | * Calling into the [min], [mid], [baseline], [max], [range] methods will trigger other [AxisSolver]s in your 28 | * layout configuration if this Solver is not fully resolved. 29 | * 30 | * Pairs of scalar resolvers create a bounding box for a laid-out view. The [XAxisSolver] computes the 31 | * left and right edges, and the [YAxisSolver] computes the top and bottom edges. 32 | * 33 | * The [YAxisSolver] may also compute the baseline. 34 | * 35 | * Most applications shouldn't need to implement this type. 36 | */ 37 | 38 | interface AxisSolver { 39 | 40 | /** 41 | * Represents the left or top point of a component in pixels relative to the parent layout's top / left 42 | * Calling this method may trigger work. 43 | */ 44 | fun min(): Int 45 | 46 | /** 47 | * Represents the center x or y point of a component in pixels relative to the parent layout's top / left 48 | * Calling this method may trigger work. 49 | */ 50 | fun mid(): Int 51 | 52 | /** 53 | * Represents the text baseline of a component with text on the y axis relative parent layout's top. 54 | * Calling this method may trigger work. 55 | * 56 | * If component does not have text - or is representing the x axis this will resolve to 0. This will probably change. 57 | */ 58 | fun baseline(): Int 59 | 60 | /** 61 | * Represents the right or bottom point of a component in pixels relative to the parent layout's top / left 62 | * Calling this method may trigger work. 63 | */ 64 | fun max(): Int 65 | 66 | /** 67 | * Represents the width or height of a component in pixels. This may be solved without any of the axis point being 68 | * solved or vice-versa 69 | * Calling this method may trigger work. 70 | */ 71 | fun range(): Int 72 | 73 | fun onAttach(parent: LayoutSpec) 74 | fun onRangeResolved(range: Int, baselineRange: Int) 75 | 76 | fun measureSpec(): Int 77 | fun clear() 78 | } 79 | 80 | interface XAxisSolver : AxisSolver 81 | interface YAxisSolver : AxisSolver 82 | -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/solvers/ComparisonResolver.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.solvers 18 | 19 | import android.view.View 20 | import com.squareup.contour.constraints.Constraint 21 | import com.squareup.contour.ContourLayout.LayoutSpec 22 | import com.squareup.contour.solvers.ComparisonResolver.CompareBy.MaxOf 23 | import com.squareup.contour.solvers.ComparisonResolver.CompareBy.MinOf 24 | 25 | internal class ComparisonResolver( 26 | private val p0: AxisSolver, 27 | private val p1: AxisSolver, 28 | private val compareBy: CompareBy 29 | ) : XAxisSolver, YAxisSolver { 30 | 31 | internal enum class CompareBy { 32 | MaxOf, 33 | MinOf 34 | } 35 | 36 | private val size = Constraint() 37 | private var found: AxisSolver? = null 38 | private lateinit var parent: LayoutSpec 39 | private var range: Int = Int.MIN_VALUE 40 | 41 | private fun findWinner(): AxisSolver { 42 | val found = found 43 | if (found != null) return found 44 | else { 45 | when (compareBy) { 46 | MaxOf -> { 47 | val max = if (p0.min() >= p1.min()) p0 else p1 48 | this.found = max 49 | return max 50 | } 51 | MinOf -> { 52 | val min = if (p0.min() <= p1.min()) p0 else p1 53 | this.found = min 54 | return min 55 | } 56 | } 57 | 58 | } 59 | } 60 | 61 | override fun min(): Int = findWinner().min() 62 | override fun mid(): Int = findWinner().mid() 63 | override fun baseline(): Int = findWinner().baseline() 64 | override fun max(): Int = findWinner().max() 65 | 66 | override fun range(): Int { 67 | if (range == Int.MIN_VALUE) { 68 | parent.measureSelf() 69 | } 70 | return range 71 | } 72 | 73 | override fun onAttach(parent: LayoutSpec) { 74 | this.parent = parent 75 | p0.onAttach(parent) 76 | p1.onAttach(parent) 77 | } 78 | 79 | override fun onRangeResolved(range: Int, baselineRange: Int) { 80 | this.range = range 81 | p0.onRangeResolved(range, baselineRange) 82 | p1.onRangeResolved(range, baselineRange) 83 | } 84 | 85 | override fun measureSpec(): Int { 86 | return if (size.isSet) { 87 | View.MeasureSpec.makeMeasureSpec(size.resolve(), View.MeasureSpec.EXACTLY) 88 | } else { 89 | 0 90 | } 91 | } 92 | 93 | override fun clear() { 94 | p0.clear() 95 | p1.clear() 96 | found = null 97 | range = Int.MIN_VALUE 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/solvers/SimpleAxisSolver.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.solvers 18 | 19 | import android.view.View 20 | import com.squareup.contour.ContourLayout.LayoutSpec 21 | import com.squareup.contour.HasBottom 22 | import com.squareup.contour.HasLeft 23 | import com.squareup.contour.HasRight 24 | import com.squareup.contour.HasTop 25 | import com.squareup.contour.HeightOfOnlyContext 26 | import com.squareup.contour.LayoutContainer 27 | import com.squareup.contour.SizeMode 28 | import com.squareup.contour.WidthOfOnlyContext 29 | import com.squareup.contour.XFloat 30 | import com.squareup.contour.XInt 31 | import com.squareup.contour.YFloat 32 | import com.squareup.contour.YInt 33 | import com.squareup.contour.constraints.Constraint 34 | import com.squareup.contour.constraints.PositionConstraint 35 | import com.squareup.contour.utils.unwrapXFloatLambda 36 | import com.squareup.contour.utils.unwrapXIntLambda 37 | import com.squareup.contour.utils.unwrapYFloatLambda 38 | import com.squareup.contour.utils.unwrapYIntLambda 39 | import kotlin.math.abs 40 | 41 | internal class SimpleAxisSolver( 42 | point: Point, 43 | lambda: LayoutContainer.() -> Int 44 | ) : 45 | XAxisSolver, HasLeft, HasRight, WidthOfOnlyContext, 46 | YAxisSolver, HasTop, HasBottom, HeightOfOnlyContext { 47 | 48 | internal enum class Point { 49 | Min, 50 | Mid, 51 | Baseline, 52 | Max 53 | } 54 | 55 | private lateinit var parent: LayoutSpec 56 | 57 | private val p0 = PositionConstraint(point, lambda) 58 | private val p1 = PositionConstraint() 59 | private val size = Constraint() 60 | 61 | private var min = Int.MIN_VALUE 62 | private var mid = Int.MIN_VALUE 63 | private var baseline = Int.MIN_VALUE 64 | private var max = Int.MIN_VALUE 65 | 66 | private var range = Int.MIN_VALUE 67 | private var baselineRange = Int.MIN_VALUE 68 | 69 | override fun min(): Int { 70 | if (min == Int.MIN_VALUE) { 71 | if (p0.point == Point.Min) { 72 | min = p0.resolve() 73 | } else { 74 | resolveRange() 75 | resolveAxis() 76 | } 77 | } 78 | return min 79 | } 80 | 81 | override fun mid(): Int { 82 | if (mid == Int.MIN_VALUE) { 83 | if (p0.point == Point.Mid) { 84 | mid = p0.resolve() 85 | } else { 86 | resolveRange() 87 | resolveAxis() 88 | } 89 | } 90 | return mid 91 | } 92 | 93 | override fun baseline(): Int { 94 | if (baseline == Int.MIN_VALUE) { 95 | if (p0.point == Point.Baseline) { 96 | baseline = p0.resolve() 97 | } else { 98 | if (baselineRange == Int.MIN_VALUE) { 99 | parent.measureSelf() 100 | } else { 101 | resolveRange() 102 | } 103 | resolveAxis() 104 | } 105 | } 106 | return baseline 107 | } 108 | 109 | override fun max(): Int { 110 | if (max == Int.MIN_VALUE) { 111 | if (p0.point == Point.Max) { 112 | max = p0.resolve() 113 | } else { 114 | resolveRange() 115 | resolveAxis() 116 | } 117 | } 118 | return max 119 | } 120 | 121 | override fun range(): Int { 122 | if (range == Int.MIN_VALUE) { 123 | resolveRange() 124 | } 125 | return range 126 | } 127 | 128 | private fun resolveRange() { 129 | if (parent.view.visibility == View.GONE) { 130 | onRangeResolved(0, 0) 131 | } else { 132 | if (p0.point == Point.Baseline && baselineRange == Int.MIN_VALUE) { 133 | parent.measureSelf() 134 | } else if (p1.isSet && p1.mode == SizeMode.Exact) { 135 | range = abs(p0.resolve() - p1.resolve()) 136 | } else if (size.isSet && size.mode == SizeMode.Exact) { 137 | range = size.resolve() 138 | } else { 139 | parent.measureSelf() 140 | } 141 | } 142 | } 143 | 144 | private fun resolveAxis() { 145 | check(range != Int.MIN_VALUE) 146 | 147 | val hV = range / 2 148 | when (p0.point) { 149 | Point.Min -> { 150 | min = p0.resolve() 151 | mid = min + hV 152 | max = min + range 153 | } 154 | Point.Mid -> { 155 | mid = p0.resolve() 156 | min = mid - hV 157 | max = mid + hV 158 | } 159 | Point.Baseline -> { 160 | check(baselineRange != Int.MIN_VALUE) 161 | baseline = p0.resolve() 162 | min = baseline - baselineRange 163 | mid = min + hV 164 | max = min + range 165 | } 166 | Point.Max -> { 167 | max = p0.resolve() 168 | mid = max - hV 169 | min = max - range 170 | } 171 | } 172 | if (p0.point != Point.Baseline && baselineRange != Int.MIN_VALUE) { 173 | baseline = min + baselineRange 174 | } 175 | } 176 | 177 | override fun onAttach(parent: LayoutSpec) { 178 | this.parent = parent 179 | p0.onAttachContext(parent) 180 | p1.onAttachContext(parent) 181 | size.onAttachContext(parent) 182 | } 183 | 184 | override fun onRangeResolved(range: Int, baselineRange: Int) { 185 | this.range = range 186 | this.baselineRange = baselineRange 187 | } 188 | 189 | override fun measureSpec(): Int { 190 | return if (p1.isSet) { 191 | View.MeasureSpec.makeMeasureSpec(abs(p0.resolve() - p1.resolve()), p1.mode.mask) 192 | } else if (size.isSet) { 193 | View.MeasureSpec.makeMeasureSpec(size.resolve(), size.mode.mask) 194 | } else { 195 | 0 196 | } 197 | } 198 | 199 | override fun clear() { 200 | min = Int.MIN_VALUE 201 | mid = Int.MIN_VALUE 202 | baseline = Int.MIN_VALUE 203 | max = Int.MIN_VALUE 204 | range = Int.MIN_VALUE 205 | baselineRange = Int.MIN_VALUE 206 | p0.clear() 207 | p1.clear() 208 | size.clear() 209 | } 210 | 211 | override fun leftTo( 212 | mode: SizeMode, 213 | provider: LayoutContainer.() -> XInt 214 | ): XAxisSolver { 215 | p1.point = Point.Min 216 | p1.mode = mode 217 | p1.lambda = unwrapXIntLambda(provider) 218 | baselineRange = 0 219 | return this 220 | } 221 | 222 | override fun leftToFloat( 223 | mode: SizeMode, 224 | provider: LayoutContainer.() -> XFloat 225 | ): XAxisSolver { 226 | p1.point = Point.Min 227 | p1.mode = mode 228 | p1.lambda = unwrapXFloatLambda(provider) 229 | baselineRange = 0 230 | return this 231 | } 232 | 233 | override fun topTo( 234 | mode: SizeMode, 235 | provider: LayoutContainer.() -> YInt 236 | ): YAxisSolver { 237 | p1.point = Point.Min 238 | p1.mode = mode 239 | p1.lambda = unwrapYIntLambda(provider) 240 | return this 241 | } 242 | 243 | override fun topToFloat( 244 | mode: SizeMode, 245 | provider: LayoutContainer.() -> YFloat 246 | ): YAxisSolver { 247 | p1.point = Point.Min 248 | p1.mode = mode 249 | p1.lambda = unwrapYFloatLambda(provider) 250 | return this 251 | } 252 | 253 | override fun rightTo(mode: SizeMode, provider: LayoutContainer.() -> XInt): XAxisSolver { 254 | p1.point = Point.Max 255 | p1.mode = mode 256 | p1.lambda = unwrapXIntLambda(provider) 257 | baselineRange = 0 258 | return this 259 | } 260 | 261 | override fun rightToFloat( 262 | mode: SizeMode, 263 | provider: LayoutContainer.() -> XFloat 264 | ): XAxisSolver { 265 | p1.point = Point.Max 266 | p1.mode = mode 267 | p1.lambda = unwrapXFloatLambda(provider) 268 | baselineRange = 0 269 | return this 270 | } 271 | 272 | override fun bottomTo( 273 | mode: SizeMode, 274 | provider: LayoutContainer.() -> YInt 275 | ): YAxisSolver { 276 | p1.point = Point.Mid 277 | p1.mode = mode 278 | p1.lambda = unwrapYIntLambda(provider) 279 | return this 280 | } 281 | 282 | override fun bottomToFloat( 283 | mode: SizeMode, 284 | provider: LayoutContainer.() -> YFloat 285 | ): YAxisSolver { 286 | p1.point = Point.Mid 287 | p1.mode = mode 288 | p1.lambda = unwrapYFloatLambda(provider) 289 | return this 290 | } 291 | 292 | override fun widthOf(mode: SizeMode, provider: LayoutContainer.() -> XInt): XAxisSolver { 293 | size.mode = mode 294 | size.lambda = unwrapXIntLambda(provider) 295 | baselineRange = 0 296 | return this 297 | } 298 | 299 | override fun widthOfFloat( 300 | mode: SizeMode, 301 | provider: LayoutContainer.() -> XFloat 302 | ): XAxisSolver { 303 | size.mode = mode 304 | size.lambda = unwrapXFloatLambda(provider) 305 | baselineRange = 0 306 | return this 307 | } 308 | 309 | override fun heightOf( 310 | mode: SizeMode, 311 | provider: LayoutContainer.() -> YInt 312 | ): YAxisSolver { 313 | size.mode = mode 314 | size.lambda = unwrapYIntLambda(provider) 315 | return this 316 | } 317 | 318 | override fun heightOfFloat( 319 | mode: SizeMode, 320 | provider: LayoutContainer.() -> YFloat 321 | ): YAxisSolver { 322 | size.mode = mode 323 | size.lambda = unwrapYFloatLambda(provider) 324 | return this 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/utils/ViewGroups.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.utils 18 | 19 | import android.view.View 20 | import android.view.ViewGroup 21 | 22 | /** 23 | * Returns a [MutableIterator] over the views in this view group. 24 | * @note @carranca: This was pulled from ktx 1.3.0 so the whole lib didn't have to get pulled in. 25 | * */ 26 | operator fun ViewGroup.iterator() = object : MutableIterator { 27 | private var index = 0 28 | override fun hasNext() = index < childCount 29 | override fun next() = getChildAt(index++) ?: throw IndexOutOfBoundsException() 30 | override fun remove() = removeViewAt(--index) 31 | } 32 | 33 | /** 34 | * Returns a [Sequence] over the child views in this view group. 35 | * @note @carranca: This was pulled from ktx 1.3.0 so the whole lib didn't have to get pulled in. 36 | * */ 37 | val ViewGroup.children: Sequence 38 | get() = object : Sequence { 39 | override fun iterator() = this@children.iterator() 40 | } -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/utils/XYIntUtils.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @file:Suppress("EXPERIMENTAL_FEATURE_WARNING", "NOTHING_TO_INLINE", "unused") 18 | 19 | package com.squareup.contour.utils 20 | 21 | import com.squareup.contour.LayoutContainer 22 | import com.squareup.contour.XFloat 23 | import com.squareup.contour.XInt 24 | import com.squareup.contour.YFloat 25 | import com.squareup.contour.YInt 26 | import com.squareup.contour.constraints.SizeConfigLambda 27 | 28 | internal inline fun unwrapXIntToXIntLambda( 29 | crossinline lambda: (XInt) -> XInt 30 | ): SizeConfigLambda = 31 | { lambda(it.toXInt()).value } 32 | 33 | internal inline fun unwrapYIntToYIntLambda( 34 | crossinline lambda: (YInt) -> YInt 35 | ): SizeConfigLambda = 36 | { lambda(it.toYInt()).value } 37 | 38 | internal inline fun unwrapXIntLambda( 39 | crossinline lambda: LayoutContainer.() -> XInt 40 | ): LayoutContainer.() -> Int = 41 | { lambda().value } 42 | 43 | internal inline fun unwrapXFloatLambda( 44 | crossinline lambda: LayoutContainer.() -> XFloat 45 | ): LayoutContainer.() -> Int = 46 | { lambda().value.toInt() } 47 | 48 | internal inline fun unwrapYIntLambda( 49 | crossinline lambda: LayoutContainer.() -> YInt 50 | ): LayoutContainer.() -> Int = 51 | { lambda().value } 52 | 53 | internal inline fun unwrapYFloatLambda( 54 | crossinline lambda: LayoutContainer.() -> YFloat 55 | ): LayoutContainer.() -> Int = 56 | { lambda().value.toInt() } 57 | 58 | internal inline fun Int.toXInt() = XInt(this) 59 | internal inline fun Int.toYInt() = YInt(this) 60 | -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/wrappers/HasDimensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.wrappers 18 | 19 | import android.view.View 20 | 21 | internal interface HasDimensions { 22 | fun measure( 23 | w: Int, 24 | h: Int 25 | ) 26 | 27 | val width: Int 28 | val height: Int 29 | val baseline: Int 30 | } 31 | 32 | internal class ViewDimensions(private val view: View) : HasDimensions { 33 | override fun measure( 34 | w: Int, 35 | h: Int 36 | ) { 37 | view.measure(w, h) 38 | } 39 | 40 | override val width: Int 41 | get() = view.measuredWidth 42 | override val height: Int 43 | get() = view.measuredHeight 44 | override val baseline: Int 45 | get() = view.baseline 46 | } 47 | -------------------------------------------------------------------------------- /contour/src/main/kotlin/com/squareup/contour/wrappers/ParentGeometry.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.wrappers 18 | 19 | import android.graphics.Rect 20 | import com.squareup.contour.Geometry 21 | import com.squareup.contour.XInt 22 | import com.squareup.contour.YInt 23 | import com.squareup.contour.constraints.SizeConfig 24 | import com.squareup.contour.utils.toXInt 25 | import com.squareup.contour.utils.toYInt 26 | 27 | internal class ParentGeometry( 28 | private val widthConfig: SizeConfig, 29 | private val heightConfig: SizeConfig, 30 | private val paddingConfig: () -> Rect 31 | ) : Geometry { 32 | override fun left(): XInt = XInt.ZERO + padding().left 33 | override fun right(): XInt = widthConfig.resolve().toXInt() - padding().right 34 | override fun width(): XInt = widthConfig.resolve().toXInt() 35 | override fun centerX(): XInt = widthConfig.resolve().toXInt() / 2 36 | 37 | override fun top(): YInt = YInt.ZERO + padding().top 38 | override fun bottom(): YInt = heightConfig.resolve().toYInt() - padding().bottom 39 | override fun height(): YInt = heightConfig.resolve().toYInt() 40 | override fun centerY(): YInt = heightConfig.resolve().toYInt() / 2 41 | 42 | override fun padding(): Rect = paddingConfig() 43 | } 44 | -------------------------------------------------------------------------------- /contour/src/test/kotlin/com/squareup/contour/ContourSizeConfigTests.kt: -------------------------------------------------------------------------------- 1 | package com.squareup.contour 2 | 3 | import android.app.Activity 4 | import android.view.View 5 | import android.view.View.GONE 6 | import android.view.View.INVISIBLE 7 | import android.view.View.VISIBLE 8 | import com.google.common.truth.Truth.assertThat 9 | import com.squareup.contour.utils.contourLayout 10 | import org.junit.Test 11 | import org.junit.runner.RunWith 12 | import org.robolectric.Robolectric 13 | import org.robolectric.RobolectricTestRunner 14 | 15 | @RunWith(RobolectricTestRunner::class) 16 | class ContourSizeConfigTests { 17 | private val activity = Robolectric.buildActivity(Activity::class.java).get() 18 | 19 | @Test 20 | fun `match parent size config, no subviews`() { 21 | val layout = contourLayout( 22 | context = activity, 23 | width = 200, 24 | height = 50 25 | ) { 26 | setPadding(1, 2, 4, 8) 27 | contourWidthMatchParent() 28 | contourHeightMatchParent() 29 | } 30 | 31 | assertThat(layout.left).isEqualTo(0) 32 | assertThat(layout.top).isEqualTo(0) 33 | assertThat(layout.right).isEqualTo(200) 34 | assertThat(layout.bottom).isEqualTo(50) 35 | assertThat(layout.width).isEqualTo(200) 36 | assertThat(layout.height).isEqualTo(50) 37 | } 38 | 39 | @Test 40 | fun `match parent size config, multiple subviews`() { 41 | val view1 = View(activity).apply { 42 | visibility = VISIBLE 43 | } 44 | val view2 = View(activity).apply { 45 | visibility = VISIBLE 46 | } 47 | val layout = contourLayout( 48 | context = activity, 49 | width = 200, 50 | height = 50 51 | ) { 52 | setPadding(1, 2, 4, 8) 53 | contourWidthMatchParent() 54 | contourHeightMatchParent() 55 | 56 | view1.layoutBy( 57 | x = leftTo { parent.left() }.widthOf { 100.toXInt() }, 58 | y = topTo { parent.top() }.heightOf { 25.toYInt() } 59 | ) 60 | 61 | view2.layoutBy( 62 | x = leftTo { parent.left() }.widthOf { 201.toXInt() }, 63 | y = topTo { parent.top() }.heightOf { 51.toYInt() } 64 | ) 65 | } 66 | 67 | assertThat(layout.left).isEqualTo(0) 68 | assertThat(layout.top).isEqualTo(0) 69 | assertThat(layout.right).isEqualTo(200) 70 | assertThat(layout.bottom).isEqualTo(50) 71 | assertThat(layout.width).isEqualTo(200) 72 | assertThat(layout.height).isEqualTo(50) 73 | } 74 | 75 | @Test 76 | fun `wrap content size config, no subviews`() { 77 | val paddingLeft = 1 78 | val paddingTop = 2 79 | val paddingRight = 4 80 | val paddingBottom = 8 81 | val layout = contourLayout( 82 | context = activity, 83 | width = 200, 84 | height = 50 85 | ) { 86 | setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom) 87 | contourWidthWrapContent() 88 | contourHeightWrapContent() 89 | } 90 | 91 | assertThat(layout.left).isEqualTo(0) 92 | assertThat(layout.top).isEqualTo(0) 93 | assertThat(layout.right).isEqualTo(paddingLeft + paddingRight) 94 | assertThat(layout.bottom).isEqualTo(paddingTop + paddingBottom) 95 | assertThat(layout.width).isEqualTo(paddingLeft + paddingRight) 96 | assertThat(layout.height).isEqualTo(paddingTop + paddingBottom) 97 | } 98 | 99 | @Test 100 | fun `wrap content size config, multiple subviews`() { 101 | val view1 = View(activity).apply { 102 | visibility = VISIBLE 103 | } 104 | val view2 = View(activity).apply { 105 | visibility = VISIBLE 106 | } 107 | val invisibleView = View(activity).apply { 108 | visibility = INVISIBLE 109 | } 110 | val goneView = View(activity).apply { 111 | visibility = GONE 112 | } 113 | 114 | val paddingLeft = 1 115 | val paddingTop = 2 116 | val paddingRight = 4 117 | val paddingBottom = 8 118 | val layout = contourLayout( 119 | context = activity, 120 | width = 200, 121 | height = 50 122 | ) { 123 | setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom) 124 | contourWidthWrapContent() 125 | contourHeightWrapContent() 126 | 127 | view1.layoutBy( 128 | x = leftTo { parent.left() }.widthOf { 100.toXInt() }, 129 | y = topTo { parent.top() }.heightOf { 25.toYInt() } 130 | ) 131 | 132 | view2.layoutBy( 133 | x = leftTo { parent.left() }.widthOf { 200.toXInt() }, 134 | y = topTo { parent.top() }.heightOf { 50.toYInt() } 135 | ) 136 | 137 | invisibleView.layoutBy( 138 | x = leftTo { parent.left() }.widthOf { 300.toXInt() }, 139 | y = topTo { parent.top() }.heightOf { 60.toYInt() } 140 | ) 141 | 142 | goneView.layoutBy( 143 | x = leftTo { parent.left() }.widthOf { 400.toXInt() }, 144 | y = topTo { view1.bottom() }.heightOf { 70.toYInt() } 145 | ) 146 | } 147 | 148 | assertThat(layout.left).isEqualTo(0) 149 | assertThat(layout.top).isEqualTo(0) 150 | assertThat(layout.right).isEqualTo(paddingLeft + 300 + paddingRight) 151 | assertThat(layout.bottom).isEqualTo(paddingTop + 60 + paddingBottom) 152 | assertThat(layout.width).isEqualTo(paddingLeft + 300 + paddingRight) 153 | assertThat(layout.height).isEqualTo(paddingTop + 60 + paddingBottom) 154 | } 155 | 156 | @Test 157 | fun `exact size config, no subviews`() { 158 | val paddingLeft = 1 159 | val paddingTop = 2 160 | val paddingRight = 4 161 | val paddingBottom = 8 162 | val layout = contourLayout( 163 | context = activity, 164 | width = 200, 165 | height = 50 166 | ) { 167 | setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom) 168 | contourWidthOf { 16.toXInt() } 169 | contourHeightOf { 32.toYInt() } 170 | } 171 | 172 | assertThat(layout.left).isEqualTo(0) 173 | assertThat(layout.top).isEqualTo(0) 174 | assertThat(layout.right).isEqualTo(16) 175 | assertThat(layout.bottom).isEqualTo(32) 176 | assertThat(layout.width).isEqualTo(16) 177 | assertThat(layout.height).isEqualTo(32) 178 | } 179 | 180 | @Test 181 | fun `exact size config, multiple subviews`() { 182 | val view1 = View(activity).apply { 183 | visibility = VISIBLE 184 | } 185 | val view2 = View(activity).apply { 186 | visibility = VISIBLE 187 | } 188 | val invisibleView = View(activity).apply { 189 | visibility = INVISIBLE 190 | } 191 | val goneView = View(activity).apply { 192 | visibility = GONE 193 | } 194 | 195 | val paddingLeft = 1 196 | val paddingTop = 2 197 | val paddingRight = 4 198 | val paddingBottom = 8 199 | val layout = contourLayout( 200 | context = activity, 201 | width = 200, 202 | height = 50 203 | ) { 204 | setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom) 205 | contourWidthOf { 16.toXInt() } 206 | contourHeightOf { 32.toYInt() } 207 | 208 | view1.layoutBy( 209 | x = leftTo { parent.left() }.widthOf { 100.toXInt() }, 210 | y = topTo { parent.top() }.heightOf { 25.toYInt() } 211 | ) 212 | 213 | view2.layoutBy( 214 | x = leftTo { parent.left() }.widthOf { 200.toXInt() }, 215 | y = topTo { parent.top() }.heightOf { 50.toYInt() } 216 | ) 217 | 218 | invisibleView.layoutBy( 219 | x = leftTo { parent.left() }.widthOf { 300.toXInt() }, 220 | y = topTo { parent.top() }.heightOf { 60.toYInt() } 221 | ) 222 | 223 | goneView.layoutBy( 224 | x = leftTo { parent.left() }.widthOf { 400.toXInt() }, 225 | y = topTo { view1.bottom() }.heightOf { 70.toYInt() } 226 | ) 227 | } 228 | 229 | assertThat(layout.left).isEqualTo(0) 230 | assertThat(layout.top).isEqualTo(0) 231 | assertThat(layout.right).isEqualTo(16) 232 | assertThat(layout.bottom).isEqualTo(32) 233 | assertThat(layout.width).isEqualTo(16) 234 | assertThat(layout.height).isEqualTo(32) 235 | } 236 | } -------------------------------------------------------------------------------- /contour/src/test/kotlin/com/squareup/contour/ContourTests.kt: -------------------------------------------------------------------------------- 1 | package com.squareup.contour 2 | 3 | import android.app.Activity 4 | import android.view.View 5 | import com.google.common.truth.Truth.assertThat 6 | import com.squareup.contour.utils.FakeTextView 7 | import com.squareup.contour.utils.contourLayout 8 | import com.squareup.contour.utils.forceRelayout 9 | import com.squareup.contour.utils.toXInt 10 | import com.squareup.contour.utils.toYInt 11 | import org.junit.Test 12 | import org.junit.runner.RunWith 13 | import org.robolectric.Robolectric 14 | import org.robolectric.RobolectricTestRunner 15 | 16 | @RunWith(RobolectricTestRunner::class) 17 | class ContourTests { 18 | 19 | private val activity = Robolectric.buildActivity(Activity::class.java).get() 20 | 21 | @Test 22 | fun `simple single child layout`() { 23 | val plainOldView = View(activity) 24 | 25 | contourLayout( 26 | context = activity, 27 | width = 200, 28 | height = 50 29 | ) { 30 | plainOldView.layoutBy( 31 | leftTo { parent.left() }.rightTo { parent.right() }, 32 | topTo { parent.top() }.bottomTo { parent.bottom() } 33 | ) 34 | } 35 | 36 | assertThat(plainOldView.left).isEqualTo(0) 37 | assertThat(plainOldView.top).isEqualTo(0) 38 | assertThat(plainOldView.right).isEqualTo(200) 39 | assertThat(plainOldView.bottom).isEqualTo(50) 40 | assertThat(plainOldView.width).isEqualTo(200) 41 | assertThat(plainOldView.height).isEqualTo(50) 42 | } 43 | 44 | @Test 45 | fun `simple layout, with padding`() { 46 | val plainOldView = View(activity) 47 | val centeredView = View(activity) 48 | 49 | val leftPadding = 1 50 | val topPadding = 2 51 | val rightPadding = 4 52 | val bottomPadding = 8 53 | 54 | contourLayout( 55 | context = activity, 56 | width = 200, 57 | height = 50 58 | ) { 59 | setPadding(leftPadding, topPadding, rightPadding, bottomPadding) 60 | plainOldView.layoutBy( 61 | leftTo { parent.left() }.rightTo { parent.right() }, 62 | topTo { parent.top() }.bottomTo { parent.bottom() } 63 | ) 64 | centeredView.layoutBy( 65 | centerHorizontallyTo { parent.centerX() }.widthOf { 10.xdip }, 66 | centerVerticallyTo { parent.centerY() }.heightOf { 10.ydip } 67 | ) 68 | } 69 | 70 | assertThat(plainOldView.left).isEqualTo(leftPadding) 71 | assertThat(plainOldView.top).isEqualTo(topPadding) 72 | assertThat(plainOldView.right).isEqualTo(200 - rightPadding) 73 | assertThat(plainOldView.bottom).isEqualTo(50 - bottomPadding) 74 | assertThat(plainOldView.width).isEqualTo(200 - leftPadding - rightPadding) 75 | assertThat(plainOldView.height).isEqualTo(50 - topPadding - bottomPadding) 76 | 77 | assertThat(centeredView.left).isEqualTo(200 / 2 - 5) 78 | assertThat(centeredView.top).isEqualTo(50 / 2 - 5) 79 | assertThat(centeredView.right).isEqualTo(200 / 2 + 5) 80 | assertThat(centeredView.bottom).isEqualTo(50 / 2 + 5) 81 | assertThat(centeredView.width).isEqualTo(10) 82 | assertThat(centeredView.height).isEqualTo(10) 83 | } 84 | 85 | @Test 86 | fun `simple layout, with padding, respectsPadding disable`() { 87 | val plainOldView = View(activity) 88 | val centeredView = View(activity) 89 | 90 | val leftPadding = 1 91 | val topPadding = 2 92 | val rightPadding = 4 93 | val bottomPadding = 8 94 | 95 | val layout = contourLayout( 96 | context = activity, 97 | width = 200, 98 | height = 50 99 | ) { 100 | respectPadding = false 101 | setPadding(leftPadding, topPadding, rightPadding, bottomPadding) 102 | plainOldView.layoutBy( 103 | leftTo { parent.left() }.rightTo { parent.right() }, 104 | topTo { parent.top() }.bottomTo { parent.bottom() } 105 | ) 106 | centeredView.layoutBy( 107 | centerHorizontallyTo { parent.centerX() }.widthOf { 10.xdip }, 108 | centerVerticallyTo { parent.centerY() }.heightOf { 10.ydip } 109 | ) 110 | } 111 | 112 | assertThat(plainOldView.left).isEqualTo(0) 113 | assertThat(plainOldView.top).isEqualTo(0) 114 | assertThat(plainOldView.right).isEqualTo(200) 115 | assertThat(plainOldView.bottom).isEqualTo(50) 116 | assertThat(plainOldView.width).isEqualTo(200) 117 | assertThat(plainOldView.height).isEqualTo(50) 118 | 119 | assertThat(centeredView.left).isEqualTo(200 / 2 - 5) 120 | assertThat(centeredView.top).isEqualTo(50 / 2 - 5) 121 | assertThat(centeredView.right).isEqualTo(200 / 2 + 5) 122 | assertThat(centeredView.bottom).isEqualTo(50 / 2 + 5) 123 | assertThat(centeredView.width).isEqualTo(10) 124 | assertThat(centeredView.height).isEqualTo(10) 125 | 126 | // Now re-enable respectPadding 127 | layout.respectPadding = true 128 | layout.forceRelayout() 129 | 130 | assertThat(plainOldView.left).isEqualTo(leftPadding) 131 | assertThat(plainOldView.top).isEqualTo(topPadding) 132 | assertThat(plainOldView.right).isEqualTo(200 - rightPadding) 133 | assertThat(plainOldView.bottom).isEqualTo(50 - bottomPadding) 134 | assertThat(plainOldView.width).isEqualTo(200 - leftPadding - rightPadding) 135 | assertThat(plainOldView.height).isEqualTo(50 - topPadding - bottomPadding) 136 | 137 | assertThat(centeredView.left).isEqualTo(200 / 2 - 5) 138 | assertThat(centeredView.top).isEqualTo(50 / 2 - 5) 139 | assertThat(centeredView.right).isEqualTo(200 / 2 + 5) 140 | assertThat(centeredView.bottom).isEqualTo(50 / 2 + 5) 141 | assertThat(centeredView.width).isEqualTo(10) 142 | assertThat(centeredView.height).isEqualTo(10) 143 | } 144 | 145 | @Test 146 | fun `child can be aligned to another child`() { 147 | val fakeImageView = View(activity) 148 | val fakeTextView = View(activity) 149 | 150 | contourLayout( 151 | context = activity, 152 | width = 200, 153 | height = 50 154 | ) { 155 | fakeImageView.layoutBy( 156 | leftTo { parent.left() }.widthOf { 157 | parent.height() 158 | .toX() 159 | }, 160 | topTo { parent.top() }.heightOf { parent.height() } 161 | ) 162 | fakeTextView.layoutBy( 163 | leftTo { fakeImageView.right() }.rightTo { parent.right() }, 164 | topTo { parent.top() }.heightOf { parent.height() } 165 | ) 166 | } 167 | 168 | assertThat(fakeImageView.width).isEqualTo(50) 169 | assertThat(fakeImageView.height).isEqualTo(50) 170 | 171 | assertThat(fakeTextView.top).isEqualTo(0) 172 | assertThat(fakeTextView.left).isEqualTo(50) 173 | assertThat(fakeTextView.width).isEqualTo(150) 174 | assertThat(fakeTextView.height).isEqualTo(50) 175 | } 176 | 177 | @Test 178 | fun `unspecified size will fallback to views preferred size`() { 179 | val fakeTextView = FakeTextView(activity, "Test", 10, 0) 180 | 181 | contourLayout(activity) { 182 | fakeTextView.layoutBy( 183 | leftTo { parent.left() }, 184 | topTo { parent.top() } 185 | ) 186 | } 187 | 188 | assertThat(fakeTextView.width).isEqualTo(40) 189 | assertThat(fakeTextView.height).isEqualTo(10) 190 | } 191 | 192 | @Test 193 | fun `specified exact size should be the final size`() { 194 | val view = View(activity) 195 | val fakeTextView = FakeTextView(activity, "Test", 10, 0) 196 | 197 | contourLayout(activity) { 198 | view.layoutBy( 199 | leftTo { parent.left() }.widthOf { 30.xdip }, 200 | topTo { parent.top() }.heightOf { 15.ydip } 201 | ) 202 | fakeTextView.layoutBy( 203 | leftTo { parent.left() }.widthOf { 50.xdip }, 204 | topTo { parent.top() }.heightOf { 20.ydip } 205 | ) 206 | } 207 | 208 | assertThat(view.width).isEqualTo(30) 209 | assertThat(view.height).isEqualTo(15) 210 | assertThat(fakeTextView.width).isEqualTo(50) 211 | assertThat(fakeTextView.height).isEqualTo(20) 212 | } 213 | 214 | @Test 215 | fun `minOf maxOf on x axis`() { 216 | val view0 = View(activity) 217 | val view1 = View(activity) 218 | 219 | var x0 = 10.toXInt() 220 | val x1 = 20.toXInt() 221 | 222 | val layout = contourLayout(activity) { 223 | view0.layoutBy( 224 | maxOf(leftTo { x0 }, leftTo { x1 }), 225 | topTo { parent.top() } 226 | ) 227 | view1.layoutBy( 228 | minOf(leftTo { x0 }, leftTo { x1 }), 229 | topTo { parent.top() } 230 | ) 231 | } 232 | 233 | assertThat(view0.left).isEqualTo(20) 234 | assertThat(view1.left).isEqualTo(10) 235 | 236 | x0 = 30.toXInt() 237 | layout.forceRelayout() 238 | 239 | assertThat(view0.left).isEqualTo(30) 240 | assertThat(view1.left).isEqualTo(20) 241 | } 242 | 243 | @Test 244 | fun `minOf maxOf on y axis`() { 245 | val view0 = View(activity) 246 | val view1 = View(activity) 247 | 248 | var y0 = 10.toYInt() 249 | val y1 = 20.toYInt() 250 | 251 | val layout = contourLayout(activity) { 252 | view0.layoutBy( 253 | leftTo { parent.left() }, 254 | maxOf(topTo { y0 }, topTo { y1 }) 255 | ) 256 | view1.layoutBy( 257 | leftTo { parent.left() }, 258 | minOf(topTo { y0 }, topTo { y1 }) 259 | ) 260 | } 261 | 262 | assertThat(view0.top).isEqualTo(20) 263 | assertThat(view1.top).isEqualTo(10) 264 | 265 | y0 = 30.toYInt() 266 | layout.forceRelayout() 267 | 268 | assertThat(view0.top).isEqualTo(30) 269 | assertThat(view1.top).isEqualTo(20) 270 | } 271 | 272 | @Test 273 | fun `width as fraction of parents width`() { 274 | val view = View(activity) 275 | 276 | var amount = 0.4f 277 | 278 | val layout = contourLayout( 279 | activity, 280 | width = 50 281 | ) { 282 | view.layoutBy( 283 | leftTo { parent.left() }.widthOfFloat { parent.width() * amount }, 284 | centerVerticallyTo { parent.centerY() } 285 | ) 286 | } 287 | 288 | assertThat(view.width).isEqualTo(20) // 50 * 0.4 289 | 290 | amount = 0.51f 291 | layout.forceRelayout() 292 | assertThat(view.width).isEqualTo(25) // 50 * 0.51 = 25.5 ~= 25 floored 293 | } 294 | 295 | @Test 296 | fun `height as fraction of parents height`() { 297 | val view = View(activity) 298 | 299 | var amount = 0.1f 300 | 301 | val layout = contourLayout( 302 | activity, 303 | width = 260 304 | ) { 305 | view.layoutBy( 306 | leftTo { parent.left() }.widthOfFloat { parent.width() * amount }, 307 | centerVerticallyTo { parent.centerY() } 308 | ) 309 | } 310 | 311 | assertThat(view.width).isEqualTo(26) // 260 * 0.1 312 | 313 | amount = 0.13f 314 | layout.forceRelayout() 315 | assertThat(view.width).isEqualTo(33) // 260 * 0.13 = 33.8 ~= 33 floored 316 | } 317 | 318 | @Test 319 | fun `conversion of XFloat to XInt and YFloat to YInt`() { 320 | val view = View(activity) 321 | contourLayout(activity, width = 260, height = 260) { 322 | view.layoutBy( 323 | leftTo { parent.left() }.widthOf { (parent.width() * 0.42f).toInt() }, 324 | topTo { parent.top() }.heightOf { (parent.height() * 0.99f).toInt() } 325 | ) 326 | } 327 | assertThat(view.width).isEqualTo(109) // 260 * 0.42 = 109.2 ~= 109 floored 328 | assertThat(view.height).isEqualTo(257) // 260 * 0.99 = 257.4 ~= 257 floored 329 | } 330 | 331 | @Test 332 | fun `conversion of XInt to XFloat and YInt to YFloat`() { 333 | val view = View(activity) 334 | contourLayout(activity, width = 260, height = 260) { 335 | view.layoutBy( 336 | leftTo { parent.left() }.widthOfFloat { parent.width().toFloat() }, 337 | topTo { parent.top() }.heightOfFloat { parent.height().toFloat() } 338 | ) 339 | } 340 | assertThat(view.width).isEqualTo(260) 341 | assertThat(view.height).isEqualTo(260) 342 | } 343 | 344 | @Test 345 | fun `view set to GONE does not get laid out and is considered to have position and size 0`() { 346 | val view = View(activity) 347 | view.visibility = View.GONE 348 | 349 | val layout = contourLayout(activity) { 350 | view.layoutBy( 351 | leftTo { parent.centerX() }, 352 | topTo { parent.centerY() } 353 | ) 354 | } 355 | 356 | assertThat(view.left).isEqualTo(0) 357 | assertThat(view.right).isEqualTo(0) 358 | assertThat(view.top).isEqualTo(0) 359 | assertThat(view.bottom).isEqualTo(0) 360 | assertThat(view.width).isEqualTo(0) 361 | assertThat(view.height).isEqualTo(0) 362 | 363 | // Now make it visible. 364 | view.visibility = View.VISIBLE 365 | layout.forceRelayout() 366 | 367 | assertThat(view.left).isEqualTo(100) 368 | assertThat(view.right).isEqualTo(100) 369 | assertThat(view.top).isEqualTo(25) 370 | assertThat(view.bottom).isEqualTo(25) 371 | assertThat(view.width).isEqualTo(0) 372 | assertThat(view.height).isEqualTo(0) 373 | } 374 | 375 | @Test 376 | fun `reference to height and width of view set to GONE should evaluate to 0`() { 377 | val viewThatIsGone = View(activity).apply { visibility = View.GONE } 378 | val otherView = View(activity) 379 | 380 | val layout = contourLayout(activity, width = 200, height = 50) { 381 | viewThatIsGone.layoutBy( 382 | leftTo { parent.centerX() } 383 | .widthOf { 10.xdip }, 384 | topTo { parent.centerY() } 385 | .heightOf { 15.ydip } 386 | ) 387 | 388 | otherView.layoutBy( 389 | leftTo { parent.left() + 5 } 390 | .widthOf { viewThatIsGone.width() + 1 }, 391 | topTo { parent.top() + 10 } 392 | .heightOf { viewThatIsGone.height() + 2 } 393 | ) 394 | } 395 | 396 | assertThat(otherView.left).isEqualTo(5) 397 | assertThat(otherView.right).isEqualTo(5 + 1) 398 | assertThat(otherView.top).isEqualTo(10) 399 | assertThat(otherView.bottom).isEqualTo(10 + 2) 400 | assertThat(otherView.width).isEqualTo(1) 401 | assertThat(otherView.height).isEqualTo(2) 402 | 403 | // Now make the view that was GONE visible. 404 | viewThatIsGone.visibility = View.VISIBLE 405 | layout.forceRelayout() 406 | 407 | assertThat(otherView.left).isEqualTo(5) 408 | assertThat(otherView.right).isEqualTo(5 + 10 + 1) 409 | assertThat(otherView.top).isEqualTo(10) 410 | assertThat(otherView.bottom).isEqualTo(10 + 15 + 2) 411 | assertThat(otherView.width).isEqualTo(10 + 1) 412 | assertThat(otherView.height).isEqualTo(15 + 2) 413 | } 414 | 415 | @Test 416 | fun `using other axis width constraint`() { 417 | val view = View(activity) 418 | 419 | contourLayout(context = activity, width = 200, height = 200) { 420 | view.layoutBy( 421 | leftTo { parent.left() }.rightTo { parent.right() - 50.dip }, 422 | topTo { parent.top() }.heightOf { view.width().toY() } 423 | ) 424 | } 425 | 426 | assertThat(view.left).isEqualTo(0) 427 | assertThat(view.top).isEqualTo(0) 428 | assertThat(view.right).isEqualTo(150) 429 | assertThat(view.bottom).isEqualTo(150) 430 | assertThat(view.width).isEqualTo(150) 431 | assertThat(view.height).isEqualTo(150) 432 | } 433 | 434 | @Test 435 | fun `using other axis height constraint`() { 436 | val view = View(activity) 437 | 438 | contourLayout(context = activity, width = 200, height = 200) { 439 | view.layoutBy( 440 | leftTo { parent.left() }.rightTo { view.height().toX() }, 441 | topTo { parent.top() }.bottomTo { 125.ydip } 442 | ) 443 | } 444 | 445 | assertThat(view.left).isEqualTo(0) 446 | assertThat(view.top).isEqualTo(0) 447 | assertThat(view.right).isEqualTo(125) 448 | assertThat(view.bottom).isEqualTo(125) 449 | assertThat(view.width).isEqualTo(125) 450 | assertThat(view.height).isEqualTo(125) 451 | } 452 | 453 | @Test 454 | fun `simple baseline`() { 455 | val fakeTextView = FakeTextView(activity, "Test", 10, 8) 456 | 457 | contourLayout(activity) { 458 | fakeTextView.layoutBy( 459 | leftTo { parent.left() }, 460 | baselineTo { 20.ydip } 461 | ) 462 | } 463 | 464 | assertThat(fakeTextView.top).isEqualTo(12) 465 | assertThat(fakeTextView.bottom).isEqualTo(22) 466 | assertThat(fakeTextView.height).isEqualTo(10) 467 | } 468 | 469 | @Test 470 | fun `baseline to baseline`() { 471 | val fakeTextView1 = FakeTextView(activity, "Hello", 18, 15) 472 | val fakeTextView2 = FakeTextView(activity, "World", 12, 10) 473 | 474 | contourLayout(activity) { 475 | fakeTextView1.layoutBy( 476 | leftTo { parent.left() }, 477 | topTo { parent.top() } 478 | ) 479 | fakeTextView2.layoutBy( 480 | rightTo { parent.right() }, 481 | baselineTo { fakeTextView1.baseline() } 482 | ) 483 | } 484 | 485 | assertThat(fakeTextView2.top).isEqualTo(5) 486 | assertThat(fakeTextView2.bottom).isEqualTo(17) 487 | assertThat(fakeTextView2.height).isEqualTo(12) 488 | } 489 | 490 | @Test 491 | fun `calling baseline() on an unmeasured view should correctly resolve its baseline`() { 492 | val view = View(activity) 493 | val text = FakeTextView(activity, "Test", 10, 8) 494 | 495 | contourLayout(activity) { 496 | view.layoutBy( 497 | leftTo { 0.xdip }, 498 | topTo { text.baseline() } 499 | ) 500 | text.layoutBy( 501 | leftTo { 0.xdip }, 502 | topTo { 0.ydip }.bottomTo { 10.ydip } 503 | ) 504 | } 505 | 506 | assertThat(view.top).isEqualTo(8) 507 | } 508 | } 509 | -------------------------------------------------------------------------------- /contour/src/test/kotlin/com/squareup/contour/XFloatTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour 18 | 19 | import com.google.common.truth.Truth.assertThat 20 | import org.junit.Test 21 | 22 | class XFloatTest { 23 | 24 | @Test 25 | fun `plus operator`() { 26 | assertThat(XFloat(10f) + 10).isEqualTo(XFloat(20f)) 27 | assertThat(XFloat(10f) + XInt(10)).isEqualTo(XFloat(20f)) 28 | assertThat(XFloat(10f) + 10f).isEqualTo(XFloat(20f)) 29 | assertThat(XFloat(10f) + XFloat(10f)).isEqualTo(XFloat(20f)) 30 | } 31 | 32 | @Test 33 | fun `minus operator`() { 34 | assertThat(XFloat(20f) - 10).isEqualTo(XFloat(10f)) 35 | assertThat(XFloat(20f) - XInt(10)).isEqualTo(XFloat(10f)) 36 | assertThat(XFloat(20f) - 10f).isEqualTo(XFloat(10f)) 37 | assertThat(XFloat(20f) - XFloat(10f)).isEqualTo(XFloat(10f)) 38 | } 39 | 40 | @Test 41 | fun `times operator`() { 42 | assertThat(XFloat(10f) * 10).isEqualTo(XFloat(100f)) 43 | assertThat(XFloat(10f) * XInt(10)).isEqualTo(XFloat(100f)) 44 | assertThat(XFloat(10f) * 10f).isEqualTo(XFloat(100f)) 45 | assertThat(XFloat(10f) * XFloat(10f)).isEqualTo(XFloat(100f)) 46 | } 47 | 48 | @Test 49 | fun `div operator`() { 50 | assertThat(XFloat(10f) / 10).isEqualTo(XFloat(1f)) 51 | assertThat(XFloat(10f) / XInt(10)).isEqualTo(XFloat(1f)) 52 | assertThat(XFloat(10f) / 10f).isEqualTo(XFloat(1f)) 53 | assertThat(XFloat(10f) / XFloat(10f)).isEqualTo(XFloat(1f)) 54 | } 55 | 56 | @Test 57 | fun `compareTo operator`() { 58 | assertThat(XFloat(10f).compareTo(10)).isEqualTo(0) 59 | assertThat(XFloat(20f).compareTo(10)).isEqualTo(1) 60 | assertThat(XFloat(10f).compareTo(20)).isEqualTo(-1) 61 | 62 | assertThat(XFloat(10f).compareTo(XInt(10))).isEqualTo(0) 63 | assertThat(XFloat(20f).compareTo(XInt(10))).isEqualTo(1) 64 | assertThat(XFloat(10f).compareTo(XInt(20))).isEqualTo(-1) 65 | 66 | assertThat(XFloat(10f).compareTo(10f)).isEqualTo(0) 67 | assertThat(XFloat(20f).compareTo(10f)).isEqualTo(1) 68 | assertThat(XFloat(10f).compareTo(20f)).isEqualTo(-1) 69 | 70 | assertThat(XFloat(10f).compareTo(XFloat(10f))).isEqualTo(0) 71 | assertThat(XFloat(20f).compareTo(XFloat(10f))).isEqualTo(1) 72 | assertThat(XFloat(10f).compareTo(XFloat(20f))).isEqualTo(-1) 73 | } 74 | 75 | @Test 76 | fun `toY method`() { 77 | assertThat(XFloat(10f).toY()).isEqualTo(YFloat(10f)) 78 | } 79 | 80 | @Test 81 | fun `toInt method`() { 82 | assertThat(XFloat(10f).toInt()).isEqualTo(XInt(10)) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /contour/src/test/kotlin/com/squareup/contour/XIntTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour 18 | 19 | import com.google.common.truth.Truth.assertThat 20 | import org.junit.Test 21 | 22 | class XIntTest { 23 | 24 | @Test 25 | fun `plus operator`() { 26 | assertThat(XInt(10) + 10).isEqualTo(XInt(20)) 27 | assertThat(XInt(10) + XInt(10)).isEqualTo(XInt(20)) 28 | assertThat(XInt(10) + 10f).isEqualTo(XFloat(20f)) 29 | assertThat(XInt(10) + XFloat(10f)).isEqualTo(XFloat(20f)) 30 | } 31 | 32 | @Test 33 | fun `minus operator`() { 34 | assertThat(XInt(20) - 10).isEqualTo(XInt(10)) 35 | assertThat(XInt(20) - XInt(10)).isEqualTo(XInt(10)) 36 | assertThat(XInt(20) - 10f).isEqualTo(XFloat(10f)) 37 | assertThat(XInt(20) - XFloat(10f)).isEqualTo(XFloat(10f)) 38 | } 39 | 40 | @Test 41 | fun `times operator`() { 42 | assertThat(XInt(10) * 10).isEqualTo(XInt(100)) 43 | assertThat(XInt(10) * XInt(10)).isEqualTo(XInt(100)) 44 | assertThat(XInt(10) * 10f).isEqualTo(XFloat(100f)) 45 | assertThat(XInt(10) * XFloat(10f)).isEqualTo(XFloat(100f)) 46 | } 47 | 48 | @Test 49 | fun `div operator`() { 50 | assertThat(XInt(10) / 10).isEqualTo(XInt(1)) 51 | assertThat(XInt(10) / XInt(10)).isEqualTo(XInt(1)) 52 | assertThat(XInt(10) / 10f).isEqualTo(XFloat(1f)) 53 | assertThat(XInt(10) / XFloat(10f)).isEqualTo(XFloat(1f)) 54 | } 55 | 56 | @Test 57 | fun `compareTo operator`() { 58 | assertThat(XInt(10).compareTo(10)).isEqualTo(0) 59 | assertThat(XInt(20).compareTo(10)).isEqualTo(1) 60 | assertThat(XInt(10).compareTo(20)).isEqualTo(-1) 61 | 62 | assertThat(XInt(10).compareTo(XInt(10))).isEqualTo(0) 63 | assertThat(XInt(20).compareTo(XInt(10))).isEqualTo(1) 64 | assertThat(XInt(10).compareTo(XInt(20))).isEqualTo(-1) 65 | 66 | assertThat(XInt(10).compareTo(10f)).isEqualTo(0) 67 | assertThat(XInt(20).compareTo(10f)).isEqualTo(1) 68 | assertThat(XInt(10).compareTo(20f)).isEqualTo(-1) 69 | 70 | assertThat(XInt(10).compareTo(XFloat(10f))).isEqualTo(0) 71 | assertThat(XInt(20).compareTo(XFloat(10f))).isEqualTo(1) 72 | assertThat(XInt(10).compareTo(XFloat(20f))).isEqualTo(-1) 73 | } 74 | 75 | @Test 76 | fun `toY method`() { 77 | assertThat(XInt(10).toY()).isEqualTo(YInt(10)) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /contour/src/test/kotlin/com/squareup/contour/YFloatTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour 18 | 19 | import com.google.common.truth.Truth.assertThat 20 | import org.junit.Test 21 | 22 | class YFloatTest { 23 | 24 | @Test 25 | fun `plus operator`() { 26 | assertThat(YFloat(10f) + 10).isEqualTo(YFloat(20f)) 27 | assertThat(YFloat(10f) + YInt(10)).isEqualTo(YFloat(20f)) 28 | assertThat(YFloat(10f) + 10f).isEqualTo(YFloat(20f)) 29 | assertThat(YFloat(10f) + YFloat(10f)).isEqualTo(YFloat(20f)) 30 | } 31 | 32 | @Test 33 | fun `minus operator`() { 34 | assertThat(YFloat(20f) - 10).isEqualTo(YFloat(10f)) 35 | assertThat(YFloat(20f) - YInt(10)).isEqualTo(YFloat(10f)) 36 | assertThat(YFloat(20f) - 10f).isEqualTo(YFloat(10f)) 37 | assertThat(YFloat(20f) - YFloat(10f)).isEqualTo(YFloat(10f)) 38 | } 39 | 40 | @Test 41 | fun `times operator`() { 42 | assertThat(YFloat(10f) * 10).isEqualTo(YFloat(100f)) 43 | assertThat(YFloat(10f) * YInt(10)).isEqualTo(YFloat(100f)) 44 | assertThat(YFloat(10f) * 10f).isEqualTo(YFloat(100f)) 45 | assertThat(YFloat(10f) * YFloat(10f)).isEqualTo(YFloat(100f)) 46 | } 47 | 48 | @Test 49 | fun `div operator`() { 50 | assertThat(YFloat(10f) / 10).isEqualTo(YFloat(1f)) 51 | assertThat(YFloat(10f) / YInt(10)).isEqualTo(YFloat(1f)) 52 | assertThat(YFloat(10f) / 10f).isEqualTo(YFloat(1f)) 53 | assertThat(YFloat(10f) / YFloat(10f)).isEqualTo(YFloat(1f)) 54 | } 55 | 56 | @Test 57 | fun `compareTo operator`() { 58 | assertThat(YFloat(10f).compareTo(10)).isEqualTo(0) 59 | assertThat(YFloat(20f).compareTo(10)).isEqualTo(1) 60 | assertThat(YFloat(10f).compareTo(20)).isEqualTo(-1) 61 | 62 | assertThat(YFloat(10f).compareTo(YInt(10))).isEqualTo(0) 63 | assertThat(YFloat(20f).compareTo(YInt(10))).isEqualTo(1) 64 | assertThat(YFloat(10f).compareTo(YInt(20))).isEqualTo(-1) 65 | 66 | assertThat(YFloat(10f).compareTo(10f)).isEqualTo(0) 67 | assertThat(YFloat(20f).compareTo(10f)).isEqualTo(1) 68 | assertThat(YFloat(10f).compareTo(20f)).isEqualTo(-1) 69 | 70 | assertThat(YFloat(10f).compareTo(YFloat(10f))).isEqualTo(0) 71 | assertThat(YFloat(20f).compareTo(YFloat(10f))).isEqualTo(1) 72 | assertThat(YFloat(10f).compareTo(YFloat(20f))).isEqualTo(-1) 73 | } 74 | 75 | @Test 76 | fun `toY method`() { 77 | assertThat(YFloat(10f).toX()).isEqualTo(XFloat(10f)) 78 | } 79 | 80 | @Test 81 | fun `toInt method`() { 82 | assertThat(YFloat(10f).toInt()).isEqualTo(YInt(10)) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /contour/src/test/kotlin/com/squareup/contour/YIntTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour 18 | 19 | import com.google.common.truth.Truth.assertThat 20 | import org.junit.Test 21 | 22 | class YIntTest { 23 | 24 | @Test 25 | fun `plus operator`() { 26 | assertThat(YInt(10) + 10).isEqualTo(YInt(20)) 27 | assertThat(YInt(10) + YInt(10)).isEqualTo(YInt(20)) 28 | assertThat(YInt(10) + 10f).isEqualTo(YFloat(20f)) 29 | assertThat(YInt(10) + YFloat(10f)).isEqualTo(YFloat(20f)) 30 | } 31 | 32 | @Test 33 | fun `minus operator`() { 34 | assertThat(YInt(20) - 10).isEqualTo(YInt(10)) 35 | assertThat(YInt(20) - YInt(10)).isEqualTo(YInt(10)) 36 | assertThat(YInt(20) - 10f).isEqualTo(YFloat(10f)) 37 | assertThat(YInt(20) - YFloat(10f)).isEqualTo(YFloat(10f)) 38 | } 39 | 40 | @Test 41 | fun `times operator`() { 42 | assertThat(YInt(10) * 10).isEqualTo(YInt(100)) 43 | assertThat(YInt(10) * YInt(10)).isEqualTo(YInt(100)) 44 | assertThat(YInt(10) * 10f).isEqualTo(YFloat(100f)) 45 | assertThat(YInt(10) * YFloat(10f)).isEqualTo(YFloat(100f)) 46 | } 47 | 48 | @Test 49 | fun `div operator`() { 50 | assertThat(YInt(10) / 10).isEqualTo(YInt(1)) 51 | assertThat(YInt(10) / YInt(10)).isEqualTo(YInt(1)) 52 | assertThat(YInt(10) / 10f).isEqualTo(YFloat(1f)) 53 | assertThat(YInt(10) / YFloat(10f)).isEqualTo(YFloat(1f)) 54 | } 55 | 56 | @Test 57 | fun `compareTo operator`() { 58 | assertThat(YInt(10).compareTo(10)).isEqualTo(0) 59 | assertThat(YInt(20).compareTo(10)).isEqualTo(1) 60 | assertThat(YInt(10).compareTo(20)).isEqualTo(-1) 61 | 62 | assertThat(YInt(10).compareTo(YInt(10))).isEqualTo(0) 63 | assertThat(YInt(20).compareTo(YInt(10))).isEqualTo(1) 64 | assertThat(YInt(10).compareTo(YInt(20))).isEqualTo(-1) 65 | 66 | assertThat(YInt(10).compareTo(10f)).isEqualTo(0) 67 | assertThat(YInt(20).compareTo(10f)).isEqualTo(1) 68 | assertThat(YInt(10).compareTo(20f)).isEqualTo(-1) 69 | 70 | assertThat(YInt(10).compareTo(YFloat(10f))).isEqualTo(0) 71 | assertThat(YInt(20).compareTo(YFloat(10f))).isEqualTo(1) 72 | assertThat(YInt(10).compareTo(YFloat(20f))).isEqualTo(-1) 73 | } 74 | 75 | @Test 76 | fun `toX method`() { 77 | assertThat(YInt(10).toX()).isEqualTo(XInt(10)) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /contour/src/test/kotlin/com/squareup/contour/utils/ContourTestHelpers.kt: -------------------------------------------------------------------------------- 1 | package com.squareup.contour.utils 2 | 3 | import android.content.Context 4 | import android.view.View 5 | import android.view.ViewGroup 6 | import com.squareup.contour.ContourLayout 7 | 8 | fun T.layoutSizeOf(width: Int, height: Int): T { 9 | measure( 10 | View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), 11 | View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY) 12 | ) 13 | layout(0, 0, measuredWidth, measuredHeight) 14 | return this 15 | } 16 | 17 | fun contourLayout( 18 | context: Context, 19 | width: Int = 200, 20 | height: Int = 50, 21 | initializeLayout: ContourLayout.() -> Unit 22 | ): ContourLayout = 23 | object : ContourLayout(context) { 24 | init { 25 | initializeLayout() 26 | } 27 | }.layoutSizeOf(width, height) 28 | 29 | fun ViewGroup.forceRelayout() { 30 | requestLayout() 31 | layoutSizeOf(measuredWidth, measuredHeight) 32 | } 33 | -------------------------------------------------------------------------------- /contour/src/test/kotlin/com/squareup/contour/utils/FakeTextView.kt: -------------------------------------------------------------------------------- 1 | package com.squareup.contour.utils 2 | 3 | import android.content.Context 4 | import android.view.View 5 | 6 | class FakeTextView( 7 | context: Context, 8 | private val text: String, 9 | private val textSize: Int, 10 | private val baseline: Int 11 | ) : View(context) { 12 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 13 | val availableWidth = MeasureSpec.getSize(widthMeasureSpec) 14 | val widthMode = MeasureSpec.getMode(widthMeasureSpec) 15 | 16 | val availableHeight = MeasureSpec.getSize(heightMeasureSpec) 17 | val heightMode = MeasureSpec.getMode(heightMeasureSpec) 18 | 19 | setMeasuredDimension( 20 | if (widthMode == MeasureSpec.EXACTLY) availableWidth 21 | else text.length * textSize, 22 | if (heightMode == MeasureSpec.EXACTLY) availableHeight 23 | else textSize 24 | ) 25 | } 26 | 27 | override fun getBaseline(): Int = baseline 28 | } 29 | -------------------------------------------------------------------------------- /contour/src/test/kotlin/com/squareup/contour/wrappers/ParentGeometryTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.wrappers 18 | 19 | import android.graphics.Rect 20 | import com.google.common.truth.Truth.assertThat 21 | import com.squareup.contour.XInt 22 | import com.squareup.contour.YInt 23 | import com.squareup.contour.constraints.SizeConfig 24 | import org.junit.Test 25 | 26 | class ParentGeometryTest { 27 | 28 | private val paddingConfig = { 29 | Rect().apply { 30 | top = 10 31 | right = 20 32 | bottom = 30 33 | left = 40 34 | } 35 | } 36 | private val parentGeometry = ParentGeometry( 37 | widthConfig = SizeConfig(available = 120, lambda = { it }), 38 | heightConfig = SizeConfig(available = 60, lambda = { it }), 39 | paddingConfig = paddingConfig 40 | ) 41 | 42 | @Test 43 | fun `left method`() { 44 | assertThat(parentGeometry.left()).isEqualTo(XInt(40)) 45 | } 46 | 47 | @Test 48 | fun `right method`() { 49 | assertThat(parentGeometry.right()).isEqualTo(XInt(100)) 50 | } 51 | 52 | @Test 53 | fun `width method`() { 54 | assertThat(parentGeometry.width()).isEqualTo(XInt(120)) 55 | } 56 | 57 | @Test 58 | fun `centerX method`() { 59 | assertThat(parentGeometry.centerX()).isEqualTo(XInt(60)) 60 | } 61 | 62 | @Test 63 | fun `top method`() { 64 | assertThat(parentGeometry.top()).isEqualTo(YInt(10)) 65 | } 66 | 67 | @Test 68 | fun `bottom method`() { 69 | assertThat(parentGeometry.bottom()).isEqualTo(YInt(30)) 70 | } 71 | 72 | @Test 73 | fun `height method`() { 74 | assertThat(parentGeometry.height()).isEqualTo(YInt(60)) 75 | } 76 | 77 | @Test 78 | fun `centerY method`() { 79 | assertThat(parentGeometry.centerY()).isEqualTo(YInt(30)) 80 | } 81 | 82 | @Test 83 | fun `padding method`() { 84 | assertThat(parentGeometry.padding().top).isEqualTo(10) 85 | assertThat(parentGeometry.padding().right).isEqualTo(20) 86 | assertThat(parentGeometry.padding().bottom).isEqualTo(30) 87 | assertThat(parentGeometry.padding().left).isEqualTo(40) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | android.useAndroidX=true 2 | # Disable BuildConfig feature by default because no modules in this repository use it 3 | android.defaults.buildfeatures.buildconfig=false 4 | org.gradle.jvmargs=-Xmx2G 5 | 6 | VERSION_NAME=1.2.0-SNAPSHOT 7 | GROUP=app.cash.contour 8 | 9 | POM_DESCRIPTION=A programmatic layout library for Android 10 | POM_URL=https://github.com/cashapp/contour 11 | POM_SCM_URL=https://github.com/cashapp/contour 12 | POM_SCM_CONNECTION=scm:git:git://github.com/cashapp/contour.git 13 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/cashapp/contour.git 14 | POM_LICENCE_NAME=The Apache Software License, Version 2.0 15 | POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt 16 | POM_LICENCE_DIST=repo 17 | POM_DEVELOPER_ID=square 18 | POM_DEVELOPER_NAME=Square, Inc. 19 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/contour/221325561e1f5eb20b7cd7724d2fc8cad26a8ab1/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.1.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MSYS* | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /sample-app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'org.jetbrains.kotlin.android' 3 | 4 | android { 5 | compileSdkVersion versions.targetSdk 6 | 7 | defaultConfig { 8 | applicationId "com.squareup.contour.sample" 9 | minSdkVersion versions.minSdk 10 | targetSdkVersion versions.targetSdk 11 | } 12 | 13 | compileOptions { 14 | sourceCompatibility JavaVersion.VERSION_1_8 15 | targetCompatibility JavaVersion.VERSION_1_8 16 | } 17 | } 18 | 19 | dependencies { 20 | implementation deps.androidx.appCompat 21 | implementation deps.picasso 22 | implementation deps.androidx.transition 23 | implementation deps.androidx.ktx 24 | implementation project(':contour') 25 | } -------------------------------------------------------------------------------- /sample-app/gradle.properties: -------------------------------------------------------------------------------- 1 | #remove after nexr Picasso release > 2.e 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /sample-app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /sample-app/src/main/java/com/squareup/contour/sample/CircularImageView.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.sample 18 | 19 | import android.content.Context 20 | import android.graphics.Canvas 21 | import android.graphics.Color 22 | import android.graphics.Paint 23 | import android.graphics.Path 24 | import androidx.appcompat.widget.AppCompatImageView 25 | 26 | class CircularImageView(context: Context) : AppCompatImageView(context) { 27 | private val path = Path() 28 | override fun onDraw(canvas: Canvas) { 29 | val r = width / 2f 30 | path.reset() 31 | path.addCircle(r, r, r, Path.Direction.CW) 32 | canvas.clipPath(path) 33 | super.onDraw(canvas) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /sample-app/src/main/java/com/squareup/contour/sample/Colors.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.sample 18 | 19 | const val Blue = 0xFF2A4F6E.toInt() 20 | const val Yellow = 0xFFAA8E39.toInt() 21 | const val Red = 0xFFAA5A39.toInt() 22 | const val Black = 0xFF000000.toInt() 23 | const val White = 0xFFFFFFFF.toInt() 24 | -------------------------------------------------------------------------------- /sample-app/src/main/java/com/squareup/contour/sample/ExpandableBioCard1.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.sample 18 | 19 | import android.annotation.SuppressLint 20 | import android.content.Context 21 | import android.graphics.Color 22 | import android.graphics.Color.DKGRAY 23 | import android.graphics.Color.WHITE 24 | import android.graphics.drawable.PaintDrawable 25 | import android.text.TextUtils.TruncateAt.END 26 | import android.util.AttributeSet 27 | import android.view.ViewGroup 28 | import android.widget.ImageView.ScaleType.CENTER_CROP 29 | import android.widget.TextView 30 | import androidx.core.view.updatePadding 31 | import androidx.interpolator.view.animation.FastOutSlowInInterpolator 32 | import androidx.transition.AutoTransition 33 | import androidx.transition.TransitionManager 34 | import com.squareup.contour.ContourLayout 35 | import com.squareup.picasso.Picasso 36 | 37 | @SuppressLint("SetTextI18n") 38 | class ExpandableBioCard1(context: Context, attrs: AttributeSet? = null) : ContourLayout(context, attrs) { 39 | private val avatar = CircularImageView(context).apply { 40 | scaleType = CENTER_CROP 41 | 42 | if (isInEditMode) { 43 | setBackgroundColor(Color.GRAY) 44 | } else { 45 | Picasso.get().load("https://upload.wikimedia.org/wikipedia/en/9/92/BenSisko.jpg").into(this) 46 | } 47 | } 48 | 49 | private val bio = TextView(context).apply { 50 | textSize = 14f 51 | text = "The Bajorans who have lived with us on this station, who have worked with us for months, " + 52 | "who helped us move this station to protect the wormhole, who joined us to explore the " + 53 | "Gamma Quadrant, who have begun to build the future of Bajor with us. These people know " + 54 | "that we are neither the enemy nor the devil. We don't always agree. We have some damn " + 55 | "good fights, in fact. But we always come away from them with a little better " + 56 | "understanding and appreciation of the other." 57 | ellipsize = END 58 | maxLines = 2 59 | setLineSpacing(0f, 1.33f) 60 | setTextColor(WHITE) 61 | } 62 | 63 | init { 64 | background = PaintDrawable(DKGRAY).also { it.setCornerRadius(32f) } 65 | clipToOutline = true 66 | elevation = 20f.dip 67 | stateListAnimator = PushOnPressAnimator(this) 68 | updatePadding(left = 16.dip, right = 16.dip) 69 | 70 | contourHeightOf { maxOf(avatar.bottom() + 24.ydip, bio.bottom() + 16.ydip) } 71 | 72 | avatar.layoutBy( 73 | x = leftTo { parent.left() }.widthOf { 60.xdip }, 74 | y = topTo { parent.top() + 24.ydip }.heightOf { 60.ydip } 75 | ) 76 | bio.layoutBy( 77 | x = leftTo { avatar.right() + 16.xdip }.rightTo { parent.right() }, 78 | y = topTo { 79 | when { 80 | isSelected -> parent.top() + 16.ydip 81 | else -> avatar.centerY() - bio.preferredHeight() / 2 82 | } 83 | } 84 | ) 85 | 86 | val collapsedLines = bio.maxLines 87 | setOnClickListener { 88 | TransitionManager.beginDelayedTransition(parent as ViewGroup, AutoTransition() 89 | .setInterpolator(FastOutSlowInInterpolator()) 90 | .setDuration(300) 91 | ) 92 | 93 | isSelected = !isSelected 94 | bio.maxLines = if (isSelected) Int.MAX_VALUE else collapsedLines 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /sample-app/src/main/java/com/squareup/contour/sample/ExpandableBioCard2.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.sample 18 | 19 | import android.animation.ObjectAnimator 20 | import android.annotation.SuppressLint 21 | import android.content.Context 22 | import android.graphics.Color 23 | import android.graphics.Color.DKGRAY 24 | import android.graphics.Typeface.BOLD 25 | import android.graphics.drawable.ColorDrawable 26 | import android.graphics.drawable.PaintDrawable 27 | import android.util.AttributeSet 28 | import android.view.KeyEvent 29 | import android.view.KeyEvent.KEYCODE_BACK 30 | import android.widget.ImageView 31 | import android.widget.ImageView.ScaleType.CENTER_CROP 32 | import android.widget.TextView 33 | import com.squareup.contour.ContourLayout 34 | import com.squareup.picasso.Picasso 35 | 36 | @SuppressLint("SetTextI18n") 37 | class ExpandableBioCard2(context: Context, attrs: AttributeSet? = null) : ContourLayout(context, attrs) { 38 | private val imageView = ImageView(context).apply { 39 | scaleType = CENTER_CROP 40 | setBackgroundColor(Color.GRAY) 41 | 42 | if (!isInEditMode) { 43 | Picasso.get() 44 | .load("https://i.imgur.com/ajdangY.jpg") 45 | .placeholder(ColorDrawable(Color.parseColor("#3d3d3d"))) 46 | .into(this) 47 | } 48 | } 49 | 50 | private val title = TextView(context).apply { 51 | textSize = 16f 52 | text = "Nicolas Cage" 53 | setTextColor(White) 54 | setTypeface(typeface, BOLD) 55 | } 56 | 57 | private val bio = TextView(context).apply { 58 | textSize = 16f 59 | text = "Nicolas Kim Coppola, known professionally as Nicolas Cage, is an American actor" + 60 | "and filmmaker. Cage has been nominated for numerous major cinematic awards, and" + 61 | "won an Academy Award, a Golden Globe, and Screen Actors Guild Award for his performance" + 62 | "in Leaving Las Vegas." 63 | setLineSpacing(0f, 1.33f) 64 | } 65 | 66 | init { 67 | background = PaintDrawable(DKGRAY) 68 | clipToOutline = true 69 | elevation = 20f.dip 70 | stateListAnimator = PushOnPressAnimator(this) 71 | registerBackPressListener() 72 | toggleCornerRadius(show = true) 73 | 74 | contourHeightOf { available -> 75 | if (isSelected) available else title.bottom() + 20.ydip 76 | } 77 | 78 | bio.layoutBy( 79 | x = matchParentX(marginLeft = 20.dip, marginRight = 20.dip), 80 | y = topTo { title.bottom() + 20.ydip } 81 | ) 82 | title.layoutBy( 83 | x = matchParentX(marginLeft = 20.dip, marginRight = 20.dip), 84 | y = topTo { imageView.bottom() + 20.ydip } 85 | ) 86 | imageView.layoutBy( 87 | x = matchParentX(), 88 | y = topTo { parent.top() }.heightOf { 200.ydip } 89 | ) 90 | } 91 | 92 | override fun getBackground() = super.getBackground() as PaintDrawable 93 | 94 | override fun setSelected(selected: Boolean) { 95 | if (isLaidOut && selected == this.isSelected) return 96 | super.setSelected(selected) 97 | toggleCornerRadius(show = !selected) 98 | } 99 | 100 | private fun toggleCornerRadius(show: Boolean) { 101 | // No idea why, but 0.0f causes the view to hide on animation end. Using 0.01 instead. 102 | val fromRadius = if (show) 0.01f else 12f.dip 103 | val toRadius = if (show) 12f.dip else 0.01f 104 | 105 | if (isLaidOut) { 106 | ObjectAnimator.ofFloat(fromRadius, toRadius) 107 | .apply { addUpdateListener { background.setCornerRadius(it.animatedValue as Float) } } 108 | .setDuration(200) 109 | .start() 110 | } else { 111 | background.setCornerRadius(toRadius) 112 | } 113 | } 114 | 115 | private fun registerBackPressListener() { 116 | isFocusableInTouchMode = true 117 | requestFocus() 118 | setOnKeyListener { _, keyCode, event -> 119 | if (isSelected && keyCode == KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { 120 | performClick() 121 | } else { 122 | false 123 | } 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /sample-app/src/main/java/com/squareup/contour/sample/PushOnPressAnimator.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.sample 18 | 19 | import android.animation.Animator 20 | import android.animation.ObjectAnimator 21 | import android.animation.PropertyValuesHolder 22 | import android.animation.StateListAnimator 23 | import android.view.View 24 | import android.view.View.SCALE_X 25 | import android.view.View.SCALE_Y 26 | import android.view.animation.AccelerateDecelerateInterpolator 27 | 28 | /** Plays a subtle push animation when [view] is pressed. */ 29 | class PushOnPressAnimator(private val view: View) : StateListAnimator() { 30 | init { 31 | addState( 32 | intArrayOf(android.R.attr.state_pressed), 33 | createAnimator(toScale = 0.95f) 34 | ) 35 | addState( 36 | intArrayOf(-android.R.attr.state_pressed), 37 | createAnimator(toScale = 1f) 38 | ) 39 | } 40 | 41 | private fun createAnimator(toScale: Float): Animator { 42 | val scaleX = PropertyValuesHolder.ofFloat(SCALE_X, toScale) 43 | val scaleY = PropertyValuesHolder.ofFloat(SCALE_Y, toScale) 44 | return ObjectAnimator.ofPropertyValuesHolder(view, scaleX, scaleY).apply { 45 | duration = 80 46 | interpolator = AccelerateDecelerateInterpolator() 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /sample-app/src/main/java/com/squareup/contour/sample/SampleActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.sample 18 | 19 | import android.os.Bundle 20 | import android.view.WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION 21 | import android.view.WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS 22 | import androidx.appcompat.app.AppCompatActivity 23 | 24 | class SampleActivity : AppCompatActivity() { 25 | override fun onCreate(savedInstanceState: Bundle?) { 26 | window.addFlags(FLAG_TRANSLUCENT_STATUS or FLAG_TRANSLUCENT_NAVIGATION) 27 | super.onCreate(savedInstanceState) 28 | setContentView(SampleView(this)) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /sample-app/src/main/java/com/squareup/contour/sample/SampleView.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.sample 18 | 19 | import android.annotation.SuppressLint 20 | import android.content.Context 21 | import android.graphics.Color 22 | import android.graphics.Color.WHITE 23 | import android.graphics.Typeface 24 | import android.graphics.Typeface.NORMAL 25 | import android.util.AttributeSet 26 | import android.view.Gravity.CENTER_VERTICAL 27 | import android.view.WindowInsets 28 | import android.view.animation.OvershootInterpolator 29 | import android.widget.TextView 30 | import androidx.core.view.updatePadding 31 | import androidx.transition.ChangeBounds 32 | import androidx.transition.TransitionManager 33 | import com.squareup.contour.ContourLayout 34 | 35 | @SuppressLint("SetTextI18n") 36 | class SampleView(context: Context, attrs: AttributeSet? = null) : ContourLayout(context, attrs) { 37 | private val toolbar = TextView(context).apply { 38 | gravity = CENTER_VERTICAL 39 | letterSpacing = 0.05f 40 | text = "Contour" 41 | textSize = 18f 42 | typeface = Typeface.create("sans-serif-medium", NORMAL) 43 | setTextColor(WHITE) 44 | updatePadding(left = 26.dip) 45 | } 46 | 47 | private val card1 = ExpandableBioCard1(context) 48 | private val card2 = ExpandableBioCard2(context) 49 | 50 | init { 51 | setBackgroundColor(Color.parseColor("#303030")) 52 | contourHeightMatchParent() 53 | 54 | toolbar.layoutBy( 55 | x = leftTo { parent.left() }, 56 | y = topTo { parent.top() + 4.ydip }.heightOf { 56.ydip } 57 | ) 58 | card1.layoutBy( 59 | x = matchParentX(marginLeft = 24.dip, marginRight = 24.dip), 60 | y = topTo { toolbar.bottom() + 8.ydip } 61 | ) 62 | 63 | val xPadding = { if (card2.isSelected) 0.xdip else 24.xdip } 64 | card2.layoutBy( 65 | x = leftTo { parent.left() + xPadding() }.rightTo { parent.right() - xPadding() }, 66 | y = topTo { 67 | if (card2.isSelected) parent.top() else card1.bottom() + 24.ydip 68 | }.heightOf { 69 | if (card2.isSelected) parent.height() else card2.preferredHeight() 70 | } 71 | ) 72 | 73 | card2.setOnClickListener { 74 | TransitionManager.beginDelayedTransition(this, ChangeBounds() 75 | .setInterpolator(OvershootInterpolator(1f)) 76 | .setDuration(400) 77 | ) 78 | 79 | card2.isSelected = !card2.isSelected 80 | requestLayout() 81 | } 82 | } 83 | 84 | override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { 85 | updatePadding(top = insets.systemWindowInsetTop) 86 | return super.onApplyWindowInsets(insets) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /sample-app/src/main/java/com/squareup/contour/sample/widget/PaddingAdjusterWidget.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Square Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.squareup.contour.sample.widget 18 | 19 | import android.annotation.SuppressLint 20 | import android.graphics.Rect 21 | import android.widget.SeekBar 22 | import androidx.appcompat.widget.AppCompatSeekBar 23 | import androidx.appcompat.widget.AppCompatTextView 24 | import com.squareup.contour.ContourLayout 25 | import com.squareup.contour.sample.SampleActivity 26 | 27 | @SuppressLint("ViewConstructor") 28 | internal class PaddingAdjusterWidget( 29 | context: SampleActivity, 30 | private val onPaddingAdjusted: (Rect) -> Unit 31 | ): ContourLayout(context) { 32 | 33 | private val onChangeListener = object : SeekBar.OnSeekBarChangeListener { 34 | override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { 35 | onPaddingAdjusted(Rect( 36 | leftSeek.progress, 37 | topSeek.progress, 38 | rightSeek.progress, 39 | bottomSeek.progress 40 | )) 41 | } 42 | 43 | override fun onStartTrackingTouch(seekBar: SeekBar?) = Unit 44 | override fun onStopTrackingTouch(seekBar: SeekBar?) = Unit 45 | } 46 | 47 | private val leftLabel = AppCompatTextView(context).apply { 48 | text = "Left" 49 | } 50 | private val topLabel = AppCompatTextView(context).apply { 51 | text = "Top" 52 | } 53 | private val rightLabel = AppCompatTextView(context).apply { 54 | text = "Right" 55 | } 56 | private val bottomLabel = AppCompatTextView(context).apply { 57 | text = "Bottom" 58 | } 59 | 60 | private val leftSeek: AppCompatSeekBar = AppCompatSeekBar(context).apply { 61 | setOnSeekBarChangeListener(onChangeListener) 62 | } 63 | private val topSeek: AppCompatSeekBar = AppCompatSeekBar(context).apply { 64 | setOnSeekBarChangeListener(onChangeListener) 65 | } 66 | private val rightSeek: AppCompatSeekBar = AppCompatSeekBar(context).apply { 67 | setOnSeekBarChangeListener(onChangeListener) 68 | } 69 | private val bottomSeek: AppCompatSeekBar = AppCompatSeekBar(context).apply { 70 | setOnSeekBarChangeListener(onChangeListener) 71 | } 72 | 73 | init { 74 | setPadding( 75 | 15.dip, 76 | 10.dip, 77 | 15.dip, 78 | 10.dip 79 | ) 80 | 81 | leftLabel.layoutBy( 82 | x = leftTo { parent.left() }, 83 | y = topTo { parent.top() } 84 | ) 85 | 86 | topLabel.layoutBy( 87 | x = leftTo { parent.left() }, 88 | y = topTo { leftLabel.bottom() + 10.dip } 89 | ) 90 | 91 | rightLabel.layoutBy( 92 | x = leftTo { parent.left() }, 93 | y = topTo { topLabel.bottom() + 10.dip } 94 | ) 95 | 96 | bottomLabel.layoutBy( 97 | x = leftTo { parent.left() }, 98 | y = topTo { rightLabel.bottom() + 10.dip } 99 | ) 100 | 101 | leftSeek.layoutBy( 102 | x = leftTo { leftLabel.right() + 20.dip } 103 | .rightTo { parent.right() }, 104 | y = centerVerticallyTo { leftLabel.centerY() } 105 | ) 106 | 107 | topSeek.layoutBy( 108 | x = leftTo { topLabel.right() + 20.dip } 109 | .rightTo { parent.right() }, 110 | y = centerVerticallyTo { topLabel.centerY() } 111 | ) 112 | 113 | rightSeek.layoutBy( 114 | x = leftTo { rightLabel.right() + 20.dip } 115 | .rightTo { parent.right() }, 116 | y = centerVerticallyTo { rightLabel.centerY() } 117 | ) 118 | 119 | bottomSeek.layoutBy( 120 | x = leftTo { bottomLabel.right() + 20.dip } 121 | .rightTo { parent.right() }, 122 | y = centerVerticallyTo { bottomLabel.centerY() } 123 | ) 124 | 125 | contourHeightOf { maxOf(bottomSeek.bottom(), bottomLabel.bottom()) + paddingBottom } 126 | } 127 | } -------------------------------------------------------------------------------- /sample-app/src/main/res/drawable/android_logo.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /sample-app/src/main/res/drawable/check_mark.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /screenshots/crd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/contour/221325561e1f5eb20b7cd7724d2fc8cad26a8ab1/screenshots/crd.png -------------------------------------------------------------------------------- /screenshots/droidcon_talk_cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/contour/221325561e1f5eb20b7cd7724d2fc8cad26a8ab1/screenshots/droidcon_talk_cover.png -------------------------------------------------------------------------------- /screenshots/runtime_layout_logic.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/contour/221325561e1f5eb20b7cd7724d2fc8cad26a8ab1/screenshots/runtime_layout_logic.gif -------------------------------------------------------------------------------- /screenshots/simple_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/contour/221325561e1f5eb20b7cd7724d2fc8cad26a8ab1/screenshots/simple_demo.gif -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'contour-parent' 2 | 3 | include ':contour' 4 | include ':contour-lint' 5 | include ':sample-app' 6 | --------------------------------------------------------------------------------