diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 90e4a1ca11..4df9c5ac03 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,7 @@ # These owners will be the default owners for everything in # the repo, unless a later match takes precedence. * @square/mobile-foundation-android zach-klippenstein + +# Any files under the compose directory in the repo root should +# be reviewed by UI Systems as well. +/compose/ @square/mobile-foundation-android @square/ui-systems-android @wardellbagby diff --git a/.github/workflows/compose-kotlin.yml b/.github/workflows/compose-kotlin.yml new file mode 100644 index 0000000000..c78e557ff0 --- /dev/null +++ b/.github/workflows/compose-kotlin.yml @@ -0,0 +1,143 @@ +name: Compose CI + +on: + push: + branches: [main] + pull_request: + paths: + - 'compose/**' + - '.github/workflows/compose-kotlin.yml' + +env: + GRADLE_HOME: ${{ github.workspace }}/gradle-home + +defaults: + run: + working-directory: compose + +jobs: + assemble: + name: Assemble + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + # These setup steps should be common across all jobs in this workflow. + - uses: actions/checkout@v2 + - uses: gradle/wrapper-validation-action@v1 + - name: set up JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + + ## Caching + - name: Cache gradle dependencies + uses: actions/cache@v1 + with: + path: ${{ env.GRADLE_HOME }}/caches + # Include the SHA in the hash so this step always adds a cache entry. If we didn't use the SHA, the artifacts + # would only get cached once for each build config hash. + # Don't use ${{ runner.os }} in the key so we don't re-assemble for UI tests. + key: gradle-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/buildSrc/**') }}-${{ github.sha }} + # The first time a SHA is assembled, we still want to load dependencies from the cache. + # Note that none of jobs dependent on this one need restore keys, since they'll always have an exact hit. + restore-keys: | + gradle-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/buildSrc/**') }}- + + # We want to keep the dependencies from the cache, but clear out the build cache which contains the actual + # compiled artifacts from this project. This ensures we don't run into any issues with stale cache entries, + # and that the resulting cache we upload for the other jobs won't waste any space on stale binaries. + # A simpler approach would be simply to delete the build-cache before uploading the cache archive, however + # if we did that in this job it would defeat the purpose of sharing that directory with dependent jobs, + # and there's no way to modify the cache after the job that created it finishes. + - name: Clean gradle build cache to assemble fresh + run: | + ls -lhrt "$GRADLE_HOME/caches" || true + rm -rf "$GRADLE_HOME/caches/build-cache-1" + ls -lhrt "$GRADLE_HOME/caches" || true + + ## Actual task + - name: Assemble with gradle + run: ./gradlew assemble --build-cache --no-daemon --stacktrace --gradle-user-home "$GRADLE_HOME" + + # Runs all check tasks in parallel. + check: + name: Check + needs: assemble + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + # Run all checks, even if some fail. + fail-fast: false + matrix: + gradle-task: + # Unit tests + - test + # Binary compatibility + - apiCheck + - lint + - ktlintCheck + - detekt + steps: + - uses: actions/checkout@v2 + - name: set up JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + + ## Caching + - name: Cache build artifacts + uses: actions/cache@v1 + with: + path: ${{ env.GRADLE_HOME }}/caches + # Don't set restore-keys so cache is always only valid for the current build config. + # Also don't use ${{ runner.os }} in the key so we don't re-assemble for UI tests. + key: gradle-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/buildSrc/**') }}-${{ github.sha }} + + ## Actual task + - name: Check with Gradle + run: ./gradlew ${{ matrix.gradle-task }} --build-cache --no-daemon --stacktrace --gradle-user-home "$GRADLE_HOME" + + instrumentation-tests: + name: Instrumentation tests + needs: assemble + runs-on: macos-latest + timeout-minutes: 30 + strategy: + # Allow tests to continue on other devices if they fail on one device. + fail-fast: false + matrix: + api-level: + # See https://github.com/square/workflow-kotlin-compose/issues/54 + # - 21 + - 24 + - 29 + steps: + - uses: actions/checkout@v2 + - name: set up JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + + ## Caching + - name: Cache build artifacts + uses: actions/cache@v1 + with: + path: ${{ env.GRADLE_HOME }}/caches + # Don't set restore-keys so cache is always only valid for the current build config. + # Also don't use ${{ runner.os }} in the key so we don't re-assemble for UI tests. + key: gradle-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/buildSrc/**') }}-${{ github.sha }} + + ## Actual task + - name: Instrumentation Tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + # This task doesn't use the run default specified at the top of the file. + working-directory: compose + script: ./gradlew connectedCheck --build-cache --no-daemon --stacktrace --gradle-user-home "$GRADLE_HOME" + - name: Upload results + uses: actions/upload-artifact@v2 + with: + name: instrumentation-test-results + path: ./**/build/reports/androidTests/connected/** diff --git a/.github/workflows/kotlin.yml b/.github/workflows/kotlin.yml index a5502ad031..70a3e5aa74 100644 --- a/.github/workflows/kotlin.yml +++ b/.github/workflows/kotlin.yml @@ -8,6 +8,8 @@ on: paths-ignore: # Don't build the entire app when just changing tutorials, which have their own workflow. - 'samples/tutorial/**' + # The compose integration has its own workflow. + - 'compose/**' jobs: dokka: diff --git a/compose/.buildscript/android-sample-app.gradle b/compose/.buildscript/android-sample-app.gradle new file mode 100644 index 0000000000..e9203bcec8 --- /dev/null +++ b/compose/.buildscript/android-sample-app.gradle @@ -0,0 +1,9 @@ +apply from: rootProject.file('.buildscript/configure-android-defaults.gradle') + +dependencies { + implementation(project(":compose-tooling")) + implementation(Deps.get("androidx.appcompat")) + implementation(Deps.get("timber")) + implementation(Deps.get("workflow.core")) + implementation(Deps.get("workflow.runtime")) +} diff --git a/compose/.buildscript/android-ui-tests.gradle b/compose/.buildscript/android-ui-tests.gradle new file mode 100644 index 0000000000..ffa0eae327 --- /dev/null +++ b/compose/.buildscript/android-ui-tests.gradle @@ -0,0 +1,16 @@ +android { + defaultConfig { + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + testOptions { + animationsDisabled = true + } +} + +dependencies { + androidTestImplementation Deps.get("compose.test") + androidTestImplementation Deps.get("test.androidx.espresso.core") + androidTestImplementation Deps.get("test.androidx.junitExt") + androidTestImplementation Deps.get("test.kotlin") + androidTestImplementation Deps.get("test.truth") +} diff --git a/compose/.buildscript/binary-validation.gradle b/compose/.buildscript/binary-validation.gradle new file mode 100644 index 0000000000..978899ca76 --- /dev/null +++ b/compose/.buildscript/binary-validation.gradle @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * We use JetBrain's Kotlin Binary Compatibility Validator to track changes to our public binary + * APIs. + * When making a change that results in a public ABI change, the apiCheck task will fail. When this + * happens, run ./gradlew apiDump to generate updated *.api files, and add those to your commit. + * See https://github.com/Kotlin/binary-compatibility-validator + */ + +apply plugin: 'binary-compatibility-validator' + +apiValidation { + // Ignore all sample projects, since they're not part of our API. + // Only leaf project name is valid configuration, and every project must be individually ignored. + // See https://github.com/Kotlin/binary-compatibility-validator/issues/3 + ignoredProjects += project('samples').name +} diff --git a/compose/.buildscript/configure-android-defaults.gradle b/compose/.buildscript/configure-android-defaults.gradle new file mode 100644 index 0000000000..87365868ca --- /dev/null +++ b/compose/.buildscript/configure-android-defaults.gradle @@ -0,0 +1,29 @@ +android { + compileSdkVersion Versions.targetSdk + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdkVersion 21 + targetSdkVersion Versions.targetSdk + versionCode 1 + versionName "1.0" + } + + buildFeatures.viewBinding = true + + // See https://github.com/Kotlin/kotlinx.coroutines/issues/1064#issuecomment-479412940 + packagingOptions { + exclude 'META-INF/*.kotlin_module' + exclude 'META-INF/AL2.0' + exclude 'META-INF/LGPL2.1' + } + + lintOptions { + // Workaround lint bug. + disable "InvalidFragmentVersionForActivityResult" + } +} diff --git a/compose/.buildscript/configure-compose.gradle b/compose/.buildscript/configure-compose.gradle new file mode 100644 index 0000000000..c9c46450e1 --- /dev/null +++ b/compose/.buildscript/configure-compose.gradle @@ -0,0 +1,25 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +android { + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerVersion Versions.kotlin + kotlinCompilerExtensionVersion Versions.compose + } +} diff --git a/compose/.buildscript/configure-maven-publish.gradle b/compose/.buildscript/configure-maven-publish.gradle new file mode 100644 index 0000000000..ad7c855d8b --- /dev/null +++ b/compose/.buildscript/configure-maven-publish.gradle @@ -0,0 +1,20 @@ +/* + * Copyright 2019 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +apply plugin: 'com.vanniktech.maven.publish' + +group = GROUP +version = VERSION_NAME diff --git a/compose/.editorconfig b/compose/.editorconfig new file mode 100644 index 0000000000..17bc1fa451 --- /dev/null +++ b/compose/.editorconfig @@ -0,0 +1,3 @@ +[*.kt] +indent_size = 2 +continuation_indent_size=4 \ No newline at end of file diff --git a/compose/.gitignore b/compose/.gitignore new file mode 100644 index 0000000000..08676baedf --- /dev/null +++ b/compose/.gitignore @@ -0,0 +1,36 @@ +# macOS +.DS_Store + +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +# Gradle +out/ +.gradle/ +build/ +local.properties + +# Intellij +*.iml +.idea/ diff --git a/compose/.idea/dictionaries/workflow.xml b/compose/.idea/dictionaries/workflow.xml new file mode 100644 index 0000000000..c2cb4ea13a --- /dev/null +++ b/compose/.idea/dictionaries/workflow.xml @@ -0,0 +1,14 @@ + + + + atomicfu + coroutine + coroutines + flowable + okio + passthrough + squareup + workflows + + + diff --git a/compose/.idea/misc.xml b/compose/.idea/misc.xml new file mode 100644 index 0000000000..24bbcf34aa --- /dev/null +++ b/compose/.idea/misc.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/compose/CHANGELOG.md b/compose/CHANGELOG.md new file mode 100644 index 0000000000..2e0df90542 --- /dev/null +++ b/compose/CHANGELOG.md @@ -0,0 +1,28 @@ +Change Log +========== + +Version 0.30.0 +-------------- + +_2020-05-31_ + +* Update Compose to dev12. (#41) +* New/Breaking: Make `renderAsState` public, make `WorkflowContainer` take a ViewEnvironment. (#32) +* Breaking: Rename `bindCompose` to `composedViewFactory`. (#35) +* Breaking: Rename `showRendering` to `WorkflowRendering` and make it not an extension function. (#36) +* Breaking: Rename `ComposeViewFactoryRoot` to `CompositionRoot` and decouple the implementation. (#34) +* Fix: Make `ViewRegistry.showRendering` update if `ViewRegistry` changes. (#33) +* Fix: Fix subcomposition of ComposableViewFactory and WorkflowRendering. (#37) + +Version 0.29.0 +-------------- + +_2020-05-18_ + +* First release from separate repo. +* Update: Compose version to dev11. (#26) +* New: Add the ability to display nested renderings with `bindCompose`. (#7) +* New: Introduce `ComposeWorkflow`, a self-rendering Workflow. (#8) +* New: Introduce tooling module with support for previewing ViewBindings with Compose's Preview. (#15) +* New: Introduce WorkflowContainer for running a workflow inside a Compose app. (#16) +* Breaking: Tidy up the package structure. (#23) diff --git a/compose/CODE_OF_CONDUCT.md b/compose/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..48fae94d34 --- /dev/null +++ b/compose/CODE_OF_CONDUCT.md @@ -0,0 +1,101 @@ +Open Source Code of Conduct +=========================== + +At Square, we are committed to contributing to the open source community and simplifying the process +of releasing and managing open source software. We’ve seen incredible support and enthusiasm from +thousands of people who have already contributed to our projects — and we want to ensure ourcommunity +continues to be truly open for everyone. + +This code of conduct outlines our expectations for participants, as well as steps to reporting +unacceptable behavior. We are committed to providing a welcoming and inspiring community for all and +expect our code of conduct to be honored. + +Square’s open source community strives to: + +* **Be open**: We invite anyone to participate in any aspect of our projects. Our community is + open, and any responsibility can be carried by a contributor who demonstrates the required + capacity and competence. + +* **Be considerate**: People use our work, and we depend on the work of others. Consider users and + colleagues before taking action. For example, changes to code, infrastructure, policy, and + documentation may negatively impact others. + +* **Be respectful**: We expect people to work together to resolve conflict, assume good intentions, + and act with empathy. Do not turn disagreements into personal attacks. + +* **Be collaborative**: Collaboration reduces redundancy and improves the quality of our work. We + strive for transparency within our open source community, and we work closely with upstream + developers and others in the free software community to coordinate our efforts. + +* **Be pragmatic**: Questions are encouraged and should be asked early in the process to avoid + problems later. Be thoughtful and considerate when seeking out the appropriate forum for your + questions. Those who are asked should be responsive and helpful. + +* **Step down considerately**: Members of every project come and go. When somebody leaves or + disengages from the project, they should make it known and take the proper steps to ensure that + others can pick up where they left off. + +This code is not exhaustive or complete. It serves to distill our common understanding of a +collaborative, shared environment, and goals. We expect it to be followed in spirit as much as in +the letter. + +Diversity Statement +------------------- + +We encourage everyone to participate and are committed to building a community for all. Although we +may not be able to satisfy everyone, we all agree that everyone is equal. + +Whenever a participant has made a mistake, we expect them to take responsibility for it. If someone +has been harmed or offended, it is our responsibility to listen carefully and respectfully, and do +our best to right the wrong. + +Although this list cannot be exhaustive, we explicitly honor diversity in age, culture, ethnicity, +gender identity or expression, language, national origin, political beliefs, profession, race, +religion, sexual orientation, socioeconomic status, and technical ability. We will not tolerate +discrimination based on any of the protected characteristics above, including participants with +disabilities. + +Reporting Issues +---------------- + +If you experience or witness unacceptable behavior — or have any other concerns — please report it by +emailing [codeofconduct@squareup.com][codeofconduct_at]. For more details, please see our Reporting +Guidelines below. + +Thanks +------ + +Some of the ideas and wording for the statements and guidelines above were based on work by the +[Twitter][twitter_coc], [Ubuntu][ubuntu_coc], [GDC][gdc_coc], and [Django][django_coc] communities. +We are thankful for their work. + +Reporting Guide +--------------- + +If you experience or witness unacceptable behavior — or have any other concerns — please report it by +emailing [codeofconduct@squareup.com][codeofconduct_at]. All reports will be handled with +discretion. + +In your report please include: + +* Your contact information. +* Names (real, nicknames, or pseudonyms) of any individuals involved. If there are additional + witnesses, please include them as well. +* Your account of what occurred, and if you believe the incident is ongoing. If there is a publicly + available record (e.g. a mailing list archive or a public IRC logger), please include a link. +* Any additional information that may be helpful. + +After filing a report, a representative from the Square Code of Conduct committee will contact you +personally. The committee will then review the incident, follow up with any additional questions, +and make a decision as to how to respond. + +Anyone asked to stop unacceptable behavior is expected to comply immediately. If an individual +engages in unacceptable behavior, the Square Code of Conduct committee may take any action they deem +appropriate, up to and including a permanent ban from all of Square spaces without warning. + +[codeofconduct_at]: mailto:codeofconduct@squareup.com +[twitter_coc]: https://github.com/twitter/code-of-conduct/blob/master/code-of-conduct.md +[ubuntu_coc]: https://ubuntu.com/community/code-of-conduct +[gdc_coc]: https://www.gdconf.com/code-of-conduct +[django_coc]: https://www.djangoproject.com/conduct/reporting/ + diff --git a/compose/CONTRIBUTING.md b/compose/CONTRIBUTING.md new file mode 100644 index 0000000000..7725594c98 --- /dev/null +++ b/compose/CONTRIBUTING.md @@ -0,0 +1,16 @@ +Contributing +============ + +If you would like to contribute code to Workflow you can do so through GitHub by +forking the repository and sending a pull request. + +When submitting code, please make every effort to follow existing conventions +and style in order to keep the code as readable as possible. Please also make +sure your code compiles by running `./gradlew clean build`. If you're using IntelliJ IDEA, +we use [Square's code style definitions][2]. + +Before your code can be accepted into the project you must also sign the +[Individual Contributor License Agreement (CLA)][1]. + + [1]: https://spreadsheets.google.com/spreadsheet/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1 + [2]: https://github.com/square/java-code-styles diff --git a/compose/LICENSE b/compose/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/compose/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/compose/README.md b/compose/README.md new file mode 100644 index 0000000000..5173043d35 --- /dev/null +++ b/compose/README.md @@ -0,0 +1,96 @@ +# workflow-kotlin-compose + +[![GitHub license](https://img.shields.io/badge/license-Apache%20License%202.0-blue.svg?style=flat)](https://www.apache.org/licenses/LICENSE-2.0) +[![Maven Central](https://img.shields.io/maven-central/v/com.squareup.workflow/workflow-ui-core-compose.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:com.squareup.workflow%20AND%20a:workflow-ui-core-compose) + +This module provides experimental support for [Jetpack Compose UI][1] with workflows. + +The only integration that is currently supported is the ability to define [ViewFactories][2] that +are implemented as `@Composable` functions. See the `hello-compose-binding` sample in `samples` for +an example of how to use. + +---- + +## Pre-Alpha + +**DO NOT USE this module in your production apps!** + +Jetpack Compose is in pre-alpha, developer preview stage. The API is incomplete and changes +_very frequently_. This integration module exists as a proof-of-concept, to show what's possible, +and to experiment with various ways to integrate Compose with Workflow. + +---- + +## Usage + +### Add the dependency + +Add the dependencies from this project (they're on Maven Central): + +```groovy +dependencies { + // Main dependency + implementation "com.squareup.workflow:workflow-ui-core-compose:${versions.workflow_compose}" + + // For the preview helpers + implementation "com.squareup.workflow:workflow-ui-compose-tooling:${versions.workflow_compose}" +} +``` + +### Enable Compose + +You must be using the latest Android Gradle Plugin 4.x version, and enable Compose support +in your `build.gradle`: + +```groovy +android { + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerVersion "1.4.0-dev-withExperimentalGoogleExtensions-20200720" + kotlinCompilerExtensionVersion "${compose_version}" + } +} +``` + +To create a `ViewFactory`, call `composedViewFactory`. The lambda passed to `composedViewFactory` is +a `@Composable` function. + +```kotlin +val HelloBinding = composedViewFactory { rendering, _ -> + MaterialTheme { + Clickable(onClick = { rendering.onClick() }) { + Text(rendering.message) + } + } +} +``` + +The `composedViewFactory` function returns a regular [`ViewFactory`][2] which can be added to a +[`ViewRegistry`][3] like any other: + +```kotlin +val viewRegistry = ViewRegistry(HelloBinding) +``` + +## License +``` +Copyright 2020 Square, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +[1]: https://developer.android.com/jetpack/compose +[2]: https://square.github.io/workflow/kotlin/api/workflow/com.squareup.workflow.ui/-view-factory/ +[3]: https://square.github.io/workflow/kotlin/api/workflow/com.squareup.workflow.ui/-view-registry/ diff --git a/compose/RELEASING.md b/compose/RELEASING.md new file mode 100644 index 0000000000..f157ff163a --- /dev/null +++ b/compose/RELEASING.md @@ -0,0 +1,104 @@ +Releasing +========= + +Production Releases +------------------- + +1. Merge an update of [the change log](CHANGELOG.md) with the changes since the last release. + +1. Make sure you're on the `main` branch (or fix branch, e.g. `v0.1-fixes`). + +1. Confirm that the kotlin build is green before committing any changes + ```bash + (cd kotlin && ./gradlew build connectedCheck) + ``` + +1. In `kotlin/gradle.properties`, remove the `-SNAPSHOT` prefix from the `VERSION_NAME` property. + E.g. `VERSION_NAME=0.1.0` + +1. Create a commit and tag the commit with the version number: + ```bash + git commit -am "Releasing v0.1.0." + git tag v0.1.0 + ``` + +1. Upload the kotlin artifacts: + ```bash + (cd kotlin && ./gradlew build && ./gradlew uploadArchives --no-parallel) + ``` + +1. Close and release the staging repository at https://oss.sonatype.org. + +1. Update the `VERSION_NAME` property in `kotlin/gradle.properties` to the new + snapshot version, e.g. `VERSION_NAME=0.2.0-SNAPSHOT`. + +1. Commit the new snapshot version: + ``` + git commit -am "Finish releasing v0.1.0." + ``` + +1. Push your commits and tag: + ``` + git push origin main + # or git push origin fix-branch + git push origin v0.1.0 + ``` + +1. Create the release on GitHub: + 1. Go to the [Releases](https://github.com/square/workflow-kotlin-compose/releases) page for the GitHub + project. + 1. Click "Draft a new release". + 1. Enter the tag name you just pushed. + 1. Title the release with the same name as the tag. + 1. Copy & paste the changelog entry for this release into the description. + 1. If this is a pre-release version, check the pre-release box. + 1. Hit "Publish release". + +1. If this was a fix release, merge changes to the main branch: + ```bash + git checkout main + git pull + git merge --no-ff v0.1-fixes + # Resolve conflicts. Accept main's versions of gradle.properties and podspecs. + git push origin main + ``` + +1. Publish the website. See below. + +--- + +## Kotlin Notes + +### Development + +To build and install the current version to your local Maven repository (`~/.m2`), run: + +```bash +./gradlew clean installArchives +``` + +### Deploying + +#### Configuration + +In order to deploy artifacts to a Maven repository, you'll need to set 4 properties in your private +Gradle properties file (`~/.gradle/gradle.properties`): + +``` +RELEASE_REPOSITORY_URL= +SNAPSHOT_REPOSITORY_URL= +SONATYPE_NEXUS_PASSWORD= +``` + +#### Snapshot Releases + +Double-check that `gradle.properties` correctly contains the `-SNAPSHOT` suffix, then upload +snapshot artifacts to Sonatype just like you would for a production release: + +```bash +./gradlew clean build && ./gradlew uploadArchives --no-parallel +``` + +You can verify the artifacts are available by visiting +https://oss.sonatype.org/content/repositories/snapshots/com/squareup/workflow/. diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts new file mode 100644 index 0000000000..24495fddbc --- /dev/null +++ b/compose/build.gradle.kts @@ -0,0 +1,117 @@ +/* + * Copyright 2017 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import io.gitlab.arturbosch.detekt.extensions.DetektExtension +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jlleitschuh.gradle.ktlint.KtlintExtension +import org.jlleitschuh.gradle.ktlint.reporter.ReporterType + +buildscript { + dependencies { + classpath(Dependencies.android_gradle_plugin) + classpath(Dependencies.detekt) + classpath(Dependencies.dokka) + classpath(Dependencies.Kotlin.binaryCompatibilityValidatorPlugin) + classpath(Dependencies.Kotlin.gradlePlugin) + classpath(Dependencies.ktlint) + classpath(Dependencies.mavenPublish) + } + + repositories { + mavenCentral() + gradlePluginPortal() + google() + // For Kotlin 1.4. + maven("https://dl.bintray.com/kotlin/kotlin-eap") + // For binary compatibility validator. + maven("https://kotlin.bintray.com/kotlinx") + } +} + +// See https://stackoverflow.com/questions/25324880/detect-ide-environment-with-gradle +val isRunningFromIde get() = project.properties["android.injected.invoked.from.ide"] == "true" + +subprojects { + repositories { + google() + mavenCentral() + jcenter() + // For Kotlin 1.4. + maven("https://dl.bintray.com/kotlin/kotlin-eap") + } + + apply(plugin = "org.jlleitschuh.gradle.ktlint") + apply(plugin = "io.gitlab.arturbosch.detekt") + afterEvaluate { + tasks.findByName("check") + ?.dependsOn("detekt") + + configurations.configureEach { + // There could be transitive dependencies in tests with a lower version. This could cause + // problems with a newer Kotlin version that we use. + resolutionStrategy.force(Dependencies.Kotlin.reflect) + } + } + + tasks.withType() { + kotlinOptions { + // Allow warnings when running from IDE, makes it easier to experiment. + if (!isRunningFromIde) { + allWarningsAsErrors = true + } + + jvmTarget = "1.8" + + // Don't panic, all this does is allow us to use the @OptIn meta-annotation. + // to define our own experiments, and some required args for compose dev15 taken from + // https://developer.android.com/jetpack/androidx/releases/compose-runtime + freeCompilerArgs += listOf( + "-Xopt-in=kotlin.RequiresOptIn", + "-Xallow-jvm-ir-dependencies", + "-Xskip-prerelease-check" + ) + + } + } + + // Configuration documentation: https://github.com/JLLeitschuh/ktlint-gradle#configuration + configure { + // Prints the name of failed rules. + verbose.set(true) + reporters { + // Default "plain" reporter is actually harder to read. + reporter(ReporterType.JSON) + } + + disabledRules.set( + setOf( + // IntelliJ refuses to sort imports correctly. + // This is a known issue: https://github.com/pinterest/ktlint/issues/527 + "import-ordering", + // Ktlint doesn't know how to handle nullary annotations on function types, e.g. + // @Composable () -> Unit. + "paren-spacing" + ) + ) + } + + configure { + config = files("${rootDir}/detekt.yml") + // Treat config file as an override for the default config. + buildUponDefaultConfig = true + } +} + +apply(from = rootProject.file(".buildscript/binary-validation.gradle")) diff --git a/compose/buildSrc/build.gradle.kts b/compose/buildSrc/build.gradle.kts new file mode 100644 index 0000000000..9db828f6bc --- /dev/null +++ b/compose/buildSrc/build.gradle.kts @@ -0,0 +1,26 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} + +kotlinDslPluginOptions { + experimentalWarning.set(false) +} diff --git a/compose/buildSrc/src/main/java/Dependencies.kt b/compose/buildSrc/src/main/java/Dependencies.kt new file mode 100644 index 0000000000..ecdf40e449 --- /dev/null +++ b/compose/buildSrc/src/main/java/Dependencies.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:JvmName("Deps") + +import java.util.Locale.US +import kotlin.reflect.full.declaredMembers + +object Versions { + const val compose = "1.0.0-alpha07" + const val kotlin = "1.4.10" + + // This *is* actually used. + @Suppress("unused") + const val targetSdk = 29 + const val workflow = "0.28.0" +} + +@Suppress("unused") +object Dependencies { + const val android_gradle_plugin = "com.android.tools.build:gradle:4.2.0-alpha15" + + object AndroidX { + const val appcompat = "androidx.appcompat:appcompat:1.1.0" + } + + object Compose { + const val foundation = "androidx.compose.foundation:foundation:${Versions.compose}" + const val layout = "androidx.compose.foundation:foundation-layout:${Versions.compose}" + const val material = "androidx.compose.material:material:${Versions.compose}" + const val savedstate = + "androidx.compose.runtime:runtime-saved-instance-state:${Versions.compose}" + const val test = "androidx.ui:ui-test:${Versions.compose}" + const val tooling = "androidx.ui:ui-tooling:${Versions.compose}" + } + + const val timber = "com.jakewharton.timber:timber:4.7.1" + + object Kotlin { + const val binaryCompatibilityValidatorPlugin = + "org.jetbrains.kotlinx:binary-compatibility-validator:0.2.3" + const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin}" + const val reflect = "org.jetbrains.kotlin:kotlin-reflect:${Versions.kotlin}" + } + + const val dokka = "org.jetbrains.dokka:dokka-gradle-plugin:0.10.0" + const val mavenPublish = "com.vanniktech:gradle-maven-publish-plugin:0.11.1" + const val ktlint = "org.jlleitschuh.gradle:ktlint-gradle:9.2.0" + const val detekt = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.0.1" + + object Test { + object AndroidX { + const val junitExt = "androidx.test.ext:junit:1.1.1" + const val runner = "androidx.test:runner:1.2.0" + const val truthExt = "androidx.test.ext:truth:1.2.0" + const val uiautomator = "androidx.test.uiautomator:uiautomator:2.2.0" + + object Espresso { + const val core = "androidx.test.espresso:espresso-core:3.2.0" + } + } + + const val junit = "junit:junit:4.13" + const val kotlin = "org.jetbrains.kotlin:kotlin-test-junit:${Versions.kotlin}" + const val truth = "com.google.truth:truth:1.0.1" + } + + object Workflow { + const val core = "com.squareup.workflow:workflow-core-jvm:${Versions.workflow}" + const val runtime = "com.squareup.workflow:workflow-runtime-jvm:${Versions.workflow}" + + object UI { + const val coreAndroid = "com.squareup.workflow:workflow-ui-core-android:${Versions.workflow}" + } + } +} + +/** + * Workaround to make [Dependencies] accessible from Groovy scripts. [path] is case-insensitive. + * + * ``` + * dependencies { + * implementation Deps.get("kotlin.stdlib.common") + * } + * ``` + */ +@JvmName("get") +fun getDependencyFromGroovy(path: String): String = try { + Dependencies.resolveObject( + path.toLowerCase(US) + .split(".") + ) +} catch (e: Throwable) { + throw IllegalArgumentException("Error resolving dependency: $path", e) +} + +private tailrec fun Any.resolveObject(pathParts: List): String { + require(pathParts.isNotEmpty()) + val klass = this::class + + if (pathParts.size == 1) { + @Suppress("UNCHECKED_CAST") + val member = klass.declaredMembers.single { it.name.toLowerCase(US) == pathParts.single() } + return member.call() as String + } + + val nestedKlasses = klass.nestedClasses + val selectedKlass = nestedKlasses.single { it.simpleName!!.toLowerCase(US) == pathParts.first() } + return selectedKlass.objectInstance!!.resolveObject(pathParts.subList(1, pathParts.size)) +} diff --git a/compose/compose-tooling/api/compose-tooling.api b/compose/compose-tooling/api/compose-tooling.api new file mode 100644 index 0000000000..6179136415 --- /dev/null +++ b/compose/compose-tooling/api/compose-tooling.api @@ -0,0 +1,15 @@ +public final class com/squareup/workflow/ui/compose/tooling/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public fun ()V +} + +public final class com/squareup/workflow/ui/compose/tooling/ComposeWorkflowsKt { + public static final fun preview (Lcom/squareup/workflow/ui/compose/ComposeWorkflow;Ljava/lang/Object;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/squareup/workflow/ui/compose/tooling/ViewFactoriesKt { + public static final fun preview (Lcom/squareup/workflow/ui/ViewFactory;Ljava/lang/Object;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V +} + diff --git a/compose/compose-tooling/build.gradle.kts b/compose/compose-tooling/build.gradle.kts new file mode 100644 index 0000000000..fe90d6941a --- /dev/null +++ b/compose/compose-tooling/build.gradle.kts @@ -0,0 +1,37 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + id("com.android.library") + kotlin("android") +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +apply(from = rootProject.file(".buildscript/configure-maven-publish.gradle")) +apply(from = rootProject.file(".buildscript/configure-android-defaults.gradle")) +apply(from = rootProject.file(".buildscript/android-ui-tests.gradle")) + +apply(from = rootProject.file(".buildscript/configure-compose.gradle")) + +dependencies { + api(project(":core-compose")) + api(Dependencies.Compose.tooling) + + implementation(Dependencies.Compose.foundation) +} diff --git a/compose/compose-tooling/gradle.properties b/compose/compose-tooling/gradle.properties new file mode 100644 index 0000000000..a2027bd02d --- /dev/null +++ b/compose/compose-tooling/gradle.properties @@ -0,0 +1,18 @@ +# +# Copyright 2020 Square Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +POM_ARTIFACT_ID=workflow-ui-compose-tooling +POM_NAME=Workflow UI Compose Tooling +POM_PACKAGING=aar diff --git a/compose/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewComposeWorkflowTest.kt b/compose/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewComposeWorkflowTest.kt new file mode 100644 index 0000000000..84b38af755 --- /dev/null +++ b/compose/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewComposeWorkflowTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("TestFunctionName", "PrivatePropertyName") + +package com.squareup.workflow.ui.compose.tooling + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.ui.test.createComposeRule +import androidx.ui.tooling.preview.Preview +import com.squareup.workflow.Workflow +import com.squareup.workflow.ui.ViewEnvironmentKey +import com.squareup.workflow.ui.compose.WorkflowRendering +import com.squareup.workflow.ui.compose.composed +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Duplicate of [PreviewViewFactoryTest] but for [com.squareup.workflow.ui.compose.ComposeWorkflow]. + */ +@RunWith(AndroidJUnit4::class) +class PreviewComposeWorkflowTest { + + @Rule @JvmField val composeRule = createComposeRule() + + @Test fun singleChild() { + composeRule.setContent { + ParentWithOneChildPreview() + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + } + + @Test fun twoChildren() { + composeRule.setContent { + ParentWithTwoChildrenPreview() + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + composeRule.onNodeWithText("three").assertIsDisplayed() + } + + @Test fun modifierIsApplied() { + composeRule.setContent { + ParentWithModifier() + } + + // The view factory will be rendered with size (0,0), so it should be reported as not displayed. + composeRule.onNodeWithText("one").assertIsNotDisplayed() + composeRule.onNodeWithText("two").assertIsNotDisplayed() + } + + @Test fun placeholderModifierIsApplied() { + composeRule.setContent { + ParentWithPlaceholderModifier() + } + + // The child will be rendered with size (0,0), so it should be reported as not displayed. + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsNotDisplayed() + } + + @Test fun customViewEnvironment() { + composeRule.setContent { + ParentConsumesCustomKeyPreview() + } + + composeRule.onNodeWithText("foo").assertIsDisplayed() + } + + private val ParentWithOneChild = + Workflow.composed, Nothing> { props, _, environment -> + Column { + BasicText(props.first) + WorkflowRendering(props.second, environment) + } + } + + @Preview @Composable private fun ParentWithOneChildPreview() { + ParentWithOneChild.preview(Pair("one", "two")) + } + + private val ParentWithTwoChildren = + Workflow.composed, Nothing> { props, _, environment -> + Column { + WorkflowRendering(rendering = props.first, viewEnvironment = environment) + BasicText(props.second) + WorkflowRendering(rendering = props.third, viewEnvironment = environment) + } + } + + @Preview @Composable private fun ParentWithTwoChildrenPreview() { + ParentWithTwoChildren.preview(Triple("one", "two", "three")) + } + + @Preview @Composable private fun ParentWithModifier() { + ParentWithOneChild.preview( + Pair("one", "two"), + modifier = Modifier.size(0.dp) + ) + } + + @Preview @Composable private fun ParentWithPlaceholderModifier() { + ParentWithOneChild.preview( + Pair("one", "two"), + placeholderModifier = Modifier.size(0.dp) + ) + } + + object TestEnvironmentKey : ViewEnvironmentKey(String::class) { + override val default: String get() = error("Not specified") + } + + private val ParentConsumesCustomKey = Workflow.composed { _, _, environment -> + BasicText(environment[TestEnvironmentKey]) + } + + @Preview @Composable private fun ParentConsumesCustomKeyPreview() { + ParentConsumesCustomKey.preview(Unit) { + it + (TestEnvironmentKey to "foo") + } + } +} diff --git a/compose/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewViewFactoryTest.kt b/compose/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewViewFactoryTest.kt new file mode 100644 index 0000000000..de471822e9 --- /dev/null +++ b/compose/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewViewFactoryTest.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("TestFunctionName", "PrivatePropertyName") + +package com.squareup.workflow.ui.compose.tooling + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.ui.test.createComposeRule +import androidx.ui.tooling.preview.Preview +import com.squareup.workflow.ui.ViewEnvironmentKey +import com.squareup.workflow.ui.compose.WorkflowRendering +import com.squareup.workflow.ui.compose.composedViewFactory +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PreviewViewFactoryTest { + + @Rule @JvmField val composeRule = createComposeRule() + + @Test fun singleChild() { + composeRule.setContent { + ParentWithOneChildPreview() + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + } + + @Test fun twoChildren() { + composeRule.setContent { + ParentWithTwoChildrenPreview() + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + composeRule.onNodeWithText("three").assertIsDisplayed() + } + + @Test fun recursive() { + composeRule.setContent { + ParentRecursivePreview() + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + composeRule.onNodeWithText("three").assertIsDisplayed() + } + + @Test fun modifierIsApplied() { + composeRule.setContent { + ParentWithModifier() + } + + // The view factory will be rendered with size (0,0), so it should be reported as not displayed. + composeRule.onNodeWithText("one").assertIsNotDisplayed() + composeRule.onNodeWithText("two").assertIsNotDisplayed() + } + + @Test fun placeholderModifierIsApplied() { + composeRule.setContent { + ParentWithPlaceholderModifier() + } + + // The child will be rendered with size (0,0), so it should be reported as not displayed. + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsNotDisplayed() + } + + @Test fun customViewEnvironment() { + composeRule.setContent { + ParentConsumesCustomKeyPreview() + } + + composeRule.onNodeWithText("foo").assertIsDisplayed() + } + + private val ParentWithOneChild = + composedViewFactory> { rendering, environment -> + Column { + BasicText(rendering.first) + WorkflowRendering(rendering.second, environment) + } + } + + @Preview @Composable private fun ParentWithOneChildPreview() { + ParentWithOneChild.preview(Pair("one", "two")) + } + + private val ParentWithTwoChildren = + composedViewFactory> { rendering, environment -> + Column { + WorkflowRendering(rendering.first, environment) + BasicText(rendering.second) + WorkflowRendering(rendering.third, environment) + } + } + + @Preview @Composable private fun ParentWithTwoChildrenPreview() { + ParentWithTwoChildren.preview(Triple("one", "two", "three")) + } + + data class RecursiveRendering( + val text: String, + val child: RecursiveRendering? = null + ) + + private val ParentRecursive = composedViewFactory { rendering, environment -> + Column { + BasicText(rendering.text) + rendering.child?.let { child -> + WorkflowRendering(rendering = child, viewEnvironment = environment) + } + } + } + + @Preview @Composable private fun ParentRecursivePreview() { + ParentRecursive.preview( + RecursiveRendering( + text = "one", + child = RecursiveRendering( + text = "two", + child = RecursiveRendering(text = "three") + ) + ) + ) + } + + @Preview @Composable private fun ParentWithModifier() { + ParentWithOneChild.preview( + Pair("one", "two"), + modifier = Modifier.size(0.dp) + ) + } + + @Preview @Composable private fun ParentWithPlaceholderModifier() { + ParentWithOneChild.preview( + Pair("one", "two"), + placeholderModifier = Modifier.size(0.dp) + ) + } + + object TestEnvironmentKey : ViewEnvironmentKey(String::class) { + override val default: String get() = error("Not specified") + } + + private val ParentConsumesCustomKey = composedViewFactory { _, environment -> + BasicText(environment[TestEnvironmentKey]) + } + + @Preview @Composable private fun ParentConsumesCustomKeyPreview() { + ParentConsumesCustomKey.preview(Unit) { + it + (TestEnvironmentKey to "foo") + } + } +} diff --git a/compose/compose-tooling/src/main/AndroidManifest.xml b/compose/compose-tooling/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..7b52cef9d5 --- /dev/null +++ b/compose/compose-tooling/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + diff --git a/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ComposeWorkflows.kt b/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ComposeWorkflows.kt new file mode 100644 index 0000000000..c13f2516f0 --- /dev/null +++ b/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ComposeWorkflows.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + +package com.squareup.workflow.ui.compose.tooling + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.squareup.workflow.Sink +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewFactory +import com.squareup.workflow.ui.ViewRegistry +import com.squareup.workflow.ui.compose.ComposeWorkflow + +/** + * Draws this [ComposeWorkflow] using a special preview [ViewRegistry]. + * + * Use inside `@Preview` Composable functions. + * + * *Note: [props] must be the `PropsT` of this [ComposeWorkflow], even though the type system does + * not enforce this constraint. This is due to a Compose compiler bug tracked + * [here](https://issuetracker.google.com/issues/156527332).* + * + * @param modifier [Modifier] that will be applied to this [ViewFactory]. + * @param placeholderModifier [Modifier] that will be applied to any nested renderings this factory + * shows. + * @param viewEnvironmentUpdater Function that configures the [ViewEnvironment] passed to this + * factory. + */ +@Composable fun ComposeWorkflow.preview( + props: PropsT, + modifier: Modifier = Modifier, + placeholderModifier: Modifier = Modifier, + viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null +) { + val previewEnvironment = previewViewEnvironment(placeholderModifier, viewEnvironmentUpdater) + Box(modifier = modifier) { + render(props, NoopSink, previewEnvironment) + } +} + +private object NoopSink : Sink { + override fun send(value: Any) = Unit +} diff --git a/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PlaceholderViewFactory.kt b/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PlaceholderViewFactory.kt new file mode 100644 index 0000000000..5623586037 --- /dev/null +++ b/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PlaceholderViewFactory.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("SameParameterValue", "DEPRECATION") + +package com.squareup.workflow.ui.compose.tooling + +import androidx.compose.foundation.background +import androidx.compose.foundation.drawBorder +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.withSaveLayer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.ui.tooling.preview.Preview +import com.squareup.workflow.ui.ViewFactory +import com.squareup.workflow.ui.compose.composedViewFactory + +/** + * A [ViewFactory] that will be used any time a [PreviewViewRegistry] is asked to show a rendering. + * It displays a placeholder graphic and the rendering's `toString()` result. + */ +internal fun placeholderViewFactory(modifier: Modifier): ViewFactory = + composedViewFactory { rendering, _ -> + BasicText( + modifier = modifier + .clipToBounds() + .drawBehind { + drawIntoCanvas { canvas -> + canvas.withSaveLayer(size.toRect(), Paint().apply { alpha = .2f }) { + canvas.drawRect(size.toRect(), Paint().apply { color = Color.Gray }) + drawCrossHatch( + color = Color.Red, + strokeWidth = 2.dp, + spaceWidth = 5.dp, + angle = 45f + ) + } + } + }, + text = rendering.toString(), + style = TextStyle( + textAlign = TextAlign.Center, + color = Color.White, + shadow = Shadow(blurRadius = 5f, color = Color.Black) + ) + ) + } + +@Preview(widthDp = 200, heightDp = 200) +@Composable private fun PreviewStubViewBindingOnWhite() { + Box(Modifier.background(Color.White)) { + PreviewStubBindingPreviewTemplate() + } +} + +@Preview(widthDp = 200, heightDp = 200) +@Composable private fun PreviewStubViewBindingOnBlack() { + Box(Modifier.background(Color.Black)) { + PreviewStubBindingPreviewTemplate() + } +} + +@Composable private fun PreviewStubBindingPreviewTemplate() { + placeholderViewFactory(Modifier).preview( + rendering = "preview", + placeholderModifier = Modifier.fillMaxSize() + .drawBorder(size = 1.dp, color = Color.Red) + ) +} + +private fun DrawScope.drawCrossHatch( + color: Color, + strokeWidth: Dp, + spaceWidth: Dp, + angle: Float +) { + drawHatch(color, strokeWidth, spaceWidth, angle) + drawHatch(color, strokeWidth, spaceWidth, angle + 90) +} + +private fun DrawScope.drawHatch( + color: Color, + strokeWidth: Dp, + spaceWidth: Dp, + angle: Float +) { + val strokeWidthPx = strokeWidth.toPx() + val spaceWidthPx = spaceWidth.toPx() + val strokeColor = color.scaleColors(.5f) + + rotate(angle) { + // Draw outside our bounds to fill the space even when rotated. + val left = -size.width + val right = size.width * 2 + val top = -size.height + val bottom = size.height * 2 + + var y = top + strokeWidthPx * 2f + while (y < bottom) { + drawLine( + strokeColor, + Offset(left, y), + Offset(right, y), + strokeWidthPx + ) + y += spaceWidthPx * 2 + } + } +} + +private fun Color.scaleColors(factor: Float) = + copy(red = red * factor, green = green * factor, blue = blue * factor) diff --git a/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PreviewViewEnvironment.kt b/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PreviewViewEnvironment.kt new file mode 100644 index 0000000000..388a0bc417 --- /dev/null +++ b/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PreviewViewEnvironment.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.workflow.ui.compose.tooling + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewFactory +import com.squareup.workflow.ui.ViewRegistry +import kotlin.reflect.KClass + +/** + * Creates and [remember]s a [ViewEnvironment] that has a special [ViewRegistry] and any additional + * elements as configured by [viewEnvironmentUpdater]. + * + * The [ViewRegistry] will contain [mainFactory] if specified, as well as a [placeholderViewFactory] + * that will be used to show any renderings that don't match [mainFactory]'s type. All placeholders + * will have [placeholderModifier] applied. + */ +@Composable internal fun previewViewEnvironment( + placeholderModifier: Modifier, + viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null, + mainFactory: ViewFactory<*>? = null +): ViewEnvironment { + val viewRegistry = remember(mainFactory, placeholderModifier) { + PreviewViewRegistry(mainFactory, placeholderViewFactory(placeholderModifier)) + } + return remember(viewRegistry, viewEnvironmentUpdater) { + ViewEnvironment(viewRegistry).let { environment -> + // Give the preview a chance to add its own elements to the ViewEnvironment. + viewEnvironmentUpdater?.let { it(environment) } ?: environment + } + } +} + +/** + * A [ViewRegistry] that uses [mainFactory] for rendering [RenderingT]s, and [placeholderFactory] + * for all other [WorkflowRendering][com.squareup.workflow.ui.compose.WorkflowRendering] calls. + */ +@Immutable +private class PreviewViewRegistry( + private val mainFactory: ViewFactory? = null, + private val placeholderFactory: ViewFactory +) : ViewRegistry { + override val keys: Set> get() = mainFactory?.let { setOf(it.type) } ?: emptySet() + + @Suppress("UNCHECKED_CAST") + override fun getFactoryFor( + renderingType: KClass + ): ViewFactory = when (renderingType) { + mainFactory?.type -> mainFactory + else -> placeholderFactory + } as ViewFactory +} diff --git a/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ViewFactories.kt b/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ViewFactories.kt new file mode 100644 index 0000000000..783a97df64 --- /dev/null +++ b/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ViewFactories.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + +package com.squareup.workflow.ui.compose.tooling + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewFactory +import com.squareup.workflow.ui.ViewRegistry +import com.squareup.workflow.ui.compose.WorkflowRendering + +/** + * Draws this [ViewFactory] using a special preview [ViewRegistry]. + * + * Use inside `@Preview` Composable functions. + * + * *Note: [rendering] must be the same type as this [ViewFactory], even though the type system does + * not enforce this constraint. This is due to a Compose compiler bug tracked + * [here](https://issuetracker.google.com/issues/156527332).* + * + * @param modifier [Modifier] that will be applied to this [ViewFactory]. + * @param placeholderModifier [Modifier] that will be applied to any nested renderings this factory + * shows. + * @param viewEnvironmentUpdater Function that configures the [ViewEnvironment] passed to this + * factory. + */ +@Composable fun ViewFactory.preview( + rendering: RenderingT, + modifier: Modifier = Modifier, + placeholderModifier: Modifier = Modifier, + viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null +) { + val previewEnvironment = + previewViewEnvironment(placeholderModifier, viewEnvironmentUpdater, mainFactory = this) + WorkflowRendering(rendering, previewEnvironment, modifier) +} diff --git a/compose/core-compose/api/core-compose.api b/compose/core-compose/api/core-compose.api new file mode 100644 index 0000000000..3ab1ac14e3 --- /dev/null +++ b/compose/core-compose/api/core-compose.api @@ -0,0 +1,58 @@ +public final class com/squareup/workflow/ui/compose/ComposeRendering { + public static final field $stable I + public static final field Companion Lcom/squareup/workflow/ui/compose/ComposeRendering$Companion; + public fun (Lkotlin/jvm/functions/Function3;)V +} + +public final class com/squareup/workflow/ui/compose/ComposeRendering$Companion { + public final fun getFactory ()Lcom/squareup/workflow/ui/ViewFactory; + public final fun getNoopRendering ()Lcom/squareup/workflow/ui/compose/ComposeRendering; +} + +public final class com/squareup/workflow/ui/compose/ComposeViewFactory : com/squareup/workflow/ui/ViewFactory { + public fun (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function4;)V + public fun buildView (Ljava/lang/Object;Lcom/squareup/workflow/ui/ViewEnvironment;Landroid/content/Context;Landroid/view/ViewGroup;)Landroid/view/View; + public fun getType ()Lkotlin/reflect/KClass; +} + +public abstract class com/squareup/workflow/ui/compose/ComposeWorkflow : com/squareup/workflow/Workflow { + public static final field $stable I + public fun ()V + public fun asStatefulWorkflow ()Lcom/squareup/workflow/StatefulWorkflow; + public abstract fun render (Ljava/lang/Object;Lcom/squareup/workflow/Sink;Lcom/squareup/workflow/ui/ViewEnvironment;Landroidx/compose/runtime/Composer;I)V +} + +public final class com/squareup/workflow/ui/compose/ComposeWorkflowKt { + public static final fun composed (Lcom/squareup/workflow/Workflow$Companion;Lkotlin/jvm/functions/Function5;)Lcom/squareup/workflow/ui/compose/ComposeWorkflow; +} + +public final class com/squareup/workflow/ui/compose/CompositionRootKt { + public static final fun withCompositionRoot (Lcom/squareup/workflow/ui/ViewEnvironment;Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow/ui/ViewEnvironment; + public static final fun withCompositionRoot (Lcom/squareup/workflow/ui/ViewRegistry;Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow/ui/ViewRegistry; +} + +public final class com/squareup/workflow/ui/compose/RenderAsStateKt { + public static final fun renderAsState (Lcom/squareup/workflow/Workflow;Lcom/squareup/workflow/diagnostic/WorkflowDiagnosticListener;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; + public static final fun renderAsState (Lcom/squareup/workflow/Workflow;Ljava/lang/Object;Lcom/squareup/workflow/diagnostic/WorkflowDiagnosticListener;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; + public static final fun renderAsState (Lcom/squareup/workflow/Workflow;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/diagnostic/WorkflowDiagnosticListener;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; + public static final fun renderAsState (Lcom/squareup/workflow/Workflow;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/diagnostic/WorkflowDiagnosticListener;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; +} + +public final class com/squareup/workflow/ui/compose/ViewEnvironmentsKt { + public static final fun WorkflowRendering (Ljava/lang/Object;Lcom/squareup/workflow/ui/ViewEnvironment;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/squareup/workflow/ui/compose/WorkflowContainerKt { + public static final fun WorkflowContainer (Lcom/squareup/workflow/Workflow;Lcom/squareup/workflow/ui/ViewEnvironment;Landroidx/compose/ui/Modifier;Lcom/squareup/workflow/diagnostic/WorkflowDiagnosticListener;Landroidx/compose/runtime/Composer;II)V + public static final fun WorkflowContainer (Lcom/squareup/workflow/Workflow;Ljava/lang/Object;Lcom/squareup/workflow/ui/ViewEnvironment;Landroidx/compose/ui/Modifier;Lcom/squareup/workflow/diagnostic/WorkflowDiagnosticListener;Landroidx/compose/runtime/Composer;II)V + public static final fun WorkflowContainer (Lcom/squareup/workflow/Workflow;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/ui/ViewEnvironment;Landroidx/compose/ui/Modifier;Lcom/squareup/workflow/diagnostic/WorkflowDiagnosticListener;Landroidx/compose/runtime/Composer;II)V + public static final fun WorkflowContainer (Lcom/squareup/workflow/Workflow;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/ui/ViewEnvironment;Landroidx/compose/ui/Modifier;Lcom/squareup/workflow/diagnostic/WorkflowDiagnosticListener;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/squareup/workflow/ui/core/compose/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public fun ()V +} + diff --git a/compose/core-compose/build.gradle.kts b/compose/core-compose/build.gradle.kts new file mode 100644 index 0000000000..ceb6d7c474 --- /dev/null +++ b/compose/core-compose/build.gradle.kts @@ -0,0 +1,39 @@ +/* + * Copyright 2019 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + id("com.android.library") + kotlin("android") +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +apply(from = rootProject.file(".buildscript/configure-maven-publish.gradle")) +apply(from = rootProject.file(".buildscript/configure-android-defaults.gradle")) +apply(from = rootProject.file(".buildscript/android-ui-tests.gradle")) + +apply(from = rootProject.file(".buildscript/configure-compose.gradle")) + +dependencies { + api(Dependencies.Workflow.UI.coreAndroid) + + implementation(Dependencies.Compose.foundation) + implementation(Dependencies.Compose.layout) + implementation(Dependencies.Compose.savedstate) + implementation(Dependencies.Workflow.runtime) +} diff --git a/compose/core-compose/gradle.properties b/compose/core-compose/gradle.properties new file mode 100644 index 0000000000..293cedcf2e --- /dev/null +++ b/compose/core-compose/gradle.properties @@ -0,0 +1,18 @@ +# +# Copyright 2019 Square Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +POM_ARTIFACT_ID=workflow-ui-core-compose +POM_NAME=Workflow UI Core Compose +POM_PACKAGING=aar diff --git a/compose/core-compose/src/androidTest/AndroidManifest.xml b/compose/core-compose/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..9ed9131013 --- /dev/null +++ b/compose/core-compose/src/androidTest/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/ComposeViewFactoryTest.kt b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/ComposeViewFactoryTest.kt new file mode 100644 index 0000000000..1b9490760e --- /dev/null +++ b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/ComposeViewFactoryTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.workflow.ui.compose + +import android.content.Context +import android.widget.FrameLayout +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.viewinterop.AndroidView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.ui.test.createComposeRule +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewRegistry +import com.squareup.workflow.ui.WorkflowViewStub +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ComposeViewFactoryTest { + + @Rule @JvmField val composeRule = createComposeRule() + + @Test fun wrapsFactoryWithRoot() { + val wrapperText = mutableStateOf("one") + val viewEnvironment = ViewEnvironment(ViewRegistry(TestFactory)) + .withCompositionRoot { content -> + Column { + BasicText(wrapperText.value) + content() + } + } + + composeRule.setContent { + AndroidView(::RootView) { + it.setViewEnvironment(viewEnvironment) + } + } + + // Compose bug doesn't let us use assertIsDisplayed on older devices. + // See https://issuetracker.google.com/issues/157728188. + composeRule.onNodeWithText("one").assertExists() + composeRule.onNodeWithText("two").assertExists() + + wrapperText.value = "ENO" + + composeRule.onNodeWithText("ENO").assertExists() + composeRule.onNodeWithText("two").assertExists() + } + + private class RootView(context: Context) : FrameLayout(context) { + private val stub = WorkflowViewStub(context).also(::addView) + + fun setViewEnvironment(viewEnvironment: ViewEnvironment) { + stub.update(TestRendering("two"), viewEnvironment) + } + } + + private data class TestRendering(val text: String) + + private companion object { + val TestFactory = composedViewFactory { rendering, _ -> + BasicText(rendering.text) + } + } +} diff --git a/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/CompositionRootTest.kt b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/CompositionRootTest.kt new file mode 100644 index 0000000000..c9ffaeeccc --- /dev/null +++ b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/CompositionRootTest.kt @@ -0,0 +1,183 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.workflow.ui.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.ui.test.createComposeRule +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertFailsWith + +@RunWith(AndroidJUnit4::class) +class CompositionRootTest { + + @Rule @JvmField val composeRule = createComposeRule() + + @Test fun wrapWithRootIfNecessary_wrapsWhenNecessary() { + val root: CompositionRoot = { content -> + Column { + BasicText("one") + content() + } + } + + composeRule.setContent { + wrapWithRootIfNecessary(root) { + BasicText("two") + } + } + + // These semantics used to merge, but as of dev15, they don't, which seems to be a bug. + // https://issuetracker.google.com/issues/161979921 + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + } + + @Test fun wrapWithRootIfNecessary_onlyWrapsOnce() { + val root: CompositionRoot = { content -> + Column { + BasicText("one") + content() + } + } + + composeRule.setContent { + wrapWithRootIfNecessary(root) { + BasicText("two") + wrapWithRootIfNecessary(root) { + BasicText("three") + } + } + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + composeRule.onNodeWithText("three").assertIsDisplayed() + } + + @Test fun wrapWithRootIfNecessary_seesUpdatesFromRootWrapper() { + val wrapperText = mutableStateOf("one") + val root: CompositionRoot = { content -> + Column { + BasicText(wrapperText.value) + content() + } + } + + composeRule.setContent { + wrapWithRootIfNecessary(root) { + BasicText("two") + } + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + wrapperText.value = "ENO" + composeRule.onNodeWithText("ENO").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + } + + @Test fun wrapWithRootIfNecessary_rewrapsWhenDifferentRoot() { + val root1: CompositionRoot = { content -> + Column { + BasicText("one") + content() + } + } + val root2: CompositionRoot = { content -> + Column { + BasicText("ENO") + content() + } + } + val viewEnvironment = mutableStateOf(root1) + + composeRule.setContent { + wrapWithRootIfNecessary(viewEnvironment.value) { + BasicText("two") + } + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + viewEnvironment.value = root2 + composeRule.onNodeWithText("ENO").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + } + + @Test fun safeComposeViewFactoryRoot_wraps_content() { + val wrapped: CompositionRoot = { content -> + Column { + BasicText("Parent") + content() + } + } + val safeRoot = safeCompositionRoot(wrapped) + + composeRule.setContent { + safeRoot { + BasicText("Child") + } + } + + composeRule.onNodeWithText("Parent").assertIsDisplayed() + composeRule.onNodeWithText("Child").assertIsDisplayed() + } + + @Test fun safeComposeViewFactoryRoot_throws_whenChildrenNotInvoked() { + val wrapped: CompositionRoot = { } + val safeRoot = safeCompositionRoot(wrapped) + + val error = assertFailsWith { + composeRule.setContent { + safeRoot {} + } + } + + assertThat(error).hasMessageThat() + .isEqualTo( + "Expected ComposableDecorator to invoke children exactly once, but was invoked 0 times." + ) + } + + @Test fun safeComposeViewFactoryRoot_throws_whenChildrenInvokedMultipleTimes() { + val wrapped: CompositionRoot = { children -> + children() + children() + } + val safeRoot = safeCompositionRoot(wrapped) + + val error = assertFailsWith { + composeRule.setContent { + safeRoot { + BasicText("Hello") + } + } + } + + assertThat(error).hasMessageThat() + .isEqualTo( + "Expected ComposableDecorator to invoke children exactly once, but was invoked 2 times." + ) + } +} diff --git a/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/RenderAsStateTest.kt b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/RenderAsStateTest.kt new file mode 100644 index 0000000000..21723b7f5b --- /dev/null +++ b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/RenderAsStateTest.kt @@ -0,0 +1,225 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + +package com.squareup.workflow.ui.compose + +import androidx.compose.runtime.Providers +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.savedinstancestate.UiSavedStateRegistry +import androidx.compose.runtime.savedinstancestate.UiSavedStateRegistryAmbient +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.ui.test.createComposeRule +import com.google.common.truth.Truth.assertThat +import com.squareup.workflow.RenderContext +import com.squareup.workflow.Snapshot +import com.squareup.workflow.StatefulWorkflow +import com.squareup.workflow.Workflow +import com.squareup.workflow.action +import com.squareup.workflow.parse +import com.squareup.workflow.readUtf8WithLength +import com.squareup.workflow.stateless +import com.squareup.workflow.ui.compose.RenderAsStateTest.SnapshottingWorkflow.SnapshottedRendering +import com.squareup.workflow.writeUtf8WithLength +import okio.ByteString +import okio.ByteString.Companion.decodeBase64 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RenderAsStateTest { + + @Rule @JvmField val composeRule = createComposeRule() + + @Test fun passesPropsThrough() { + val workflow = Workflow.stateless { it } + lateinit var initialRendering: String + + composeRule.setContent { + initialRendering = workflow.renderAsState("foo").value + } + + composeRule.runOnIdle { + assertThat(initialRendering).isEqualTo("foo") + } + } + + @Test fun seesPropsAndRenderingUpdates() { + val workflow = Workflow.stateless { it } + val props = mutableStateOf("foo") + lateinit var rendering: String + + composeRule.setContent { + rendering = workflow.renderAsState(props.value).value + } + + composeRule.waitForIdle() + assertThat(rendering).isEqualTo("foo") + props.value = "bar" + composeRule.waitForIdle() + assertThat(rendering).isEqualTo("bar") + } + + @Test fun invokesOutputCallback() { + val workflow = Workflow.stateless Unit> { + { string -> actionSink.send(action { setOutput(string) }) } + } + val receivedOutputs = mutableListOf() + lateinit var rendering: (String) -> Unit + + composeRule.setContent { + rendering = workflow.renderAsState(onOutput = { receivedOutputs += it }).value + } + + composeRule.waitForIdle() + assertThat(receivedOutputs).isEmpty() + rendering("one") + + composeRule.waitForIdle() + assertThat(receivedOutputs).isEqualTo(listOf("one")) + rendering("two") + + composeRule.waitForIdle() + assertThat(receivedOutputs).isEqualTo(listOf("one", "two")) + } + + @Test fun savesSnapshot() { + val workflow = SnapshottingWorkflow() + val savedStateRegistry = UiSavedStateRegistry(emptyMap()) { true } + lateinit var rendering: SnapshottedRendering + + composeRule.setContent { + Providers(UiSavedStateRegistryAmbient provides savedStateRegistry) { + rendering = renderAsStateImpl( + workflow, + props = Unit, + onOutput = {}, + diagnosticListener = null, + snapshotKey = SNAPSHOT_KEY + ).value + } + } + + composeRule.waitForIdle() + assertThat(rendering.string).isEmpty() + rendering.updateString("foo") + + composeRule.waitForIdle() + val savedValues = savedStateRegistry.performSave() + println("saved keys: ${savedValues.keys}") + // Relying on the int key across all runtimes is brittle, so use an explicit key. + val snapshot = ByteString.of(*(savedValues.getValue(SNAPSHOT_KEY).single() as ByteArray)) + println("snapshot: ${snapshot.base64()}") + assertThat(snapshot).isEqualTo(EXPECTED_SNAPSHOT) + } + + @Test fun restoresSnapshot() { + val workflow = SnapshottingWorkflow() + val restoreValues = mapOf(SNAPSHOT_KEY to listOf(EXPECTED_SNAPSHOT.toByteArray())) + val savedStateRegistry = UiSavedStateRegistry(restoreValues) { true } + lateinit var rendering: SnapshottedRendering + + composeRule.setContent { + Providers(UiSavedStateRegistryAmbient provides savedStateRegistry) { + rendering = renderAsStateImpl( + workflow, + props = Unit, + onOutput = {}, + diagnosticListener = null, + snapshotKey = "workflow-snapshot" + ).value + } + } + + composeRule.waitForIdle() + assertThat(rendering.string).isEqualTo("foo") + } + + @Test fun restoresFromSnapshotWhenWorkflowChanged() { + val workflow1 = SnapshottingWorkflow() + val workflow2 = SnapshottingWorkflow() + val currentWorkflow = mutableStateOf(workflow1) + lateinit var rendering: SnapshottedRendering + + var compositionCount = 0 + var lastCompositionCount = 0 + fun assertWasRecomposed() { + assertThat(compositionCount).isGreaterThan(lastCompositionCount) + lastCompositionCount = compositionCount + } + + composeRule.setContent { + compositionCount++ + rendering = currentWorkflow.value.renderAsState().value + } + + // Initialize the first workflow. + composeRule.waitForIdle() + assertThat(rendering.string).isEmpty() + assertWasRecomposed() + rendering.updateString("one") + composeRule.waitForIdle() + assertWasRecomposed() + assertThat(rendering.string).isEqualTo("one") + + // Change the workflow instance being rendered. This should restart the runtime, but restore + // it from the snapshot. + currentWorkflow.value = workflow2 + + composeRule.waitForIdle() + assertWasRecomposed() + assertThat(rendering.string).isEqualTo("one") + } + + private companion object { + const val SNAPSHOT_KEY = "workflow-snapshot" + + /** Golden value from [savesSnapshot]. */ + val EXPECTED_SNAPSHOT = "AAAABwAAAANmb28AAAAA".decodeBase64()!! + } + + // Seems to be a problem accessing Workflow.stateful. + private class SnapshottingWorkflow : + StatefulWorkflow() { + + data class SnapshottedRendering( + val string: String, + val updateString: (String) -> Unit + ) + + override fun initialState( + props: Unit, + snapshot: Snapshot? + ): String = snapshot?.bytes?.parse { it.readUtf8WithLength() } ?: "" + + override fun render( + props: Unit, + state: String, + context: RenderContext + ) = SnapshottedRendering( + string = state, + updateString = { newString -> context.actionSink.send(updateString(newString)) } + ) + + override fun snapshotState(state: String): Snapshot = + Snapshot.write { it.writeUtf8WithLength(state) } + + private fun updateString(newString: String) = action { + nextState = newString + } + } +} diff --git a/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/ViewEnvironmentsTest.kt b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/ViewEnvironmentsTest.kt new file mode 100644 index 0000000000..2328505394 --- /dev/null +++ b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/ViewEnvironmentsTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.workflow.ui.compose + +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.ui.test.createComposeRule +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewRegistry +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ViewEnvironmentsTest { + + @Rule @JvmField val composeRule = createComposeRule() + + @Test fun workflowRendering_recomposes_whenFactoryChanged() { + val registry1 = ViewRegistry(composedViewFactory { rendering, _ -> + BasicText(rendering) + }) + val registry2 = ViewRegistry(composedViewFactory { rendering, _ -> + BasicText(rendering.reversed()) + }) + val registry = mutableStateOf(registry1) + + composeRule.setContent { + WorkflowRendering("hello", ViewEnvironment(registry.value)) + } + + composeRule.onNodeWithText("hello").assertIsDisplayed() + registry.value = registry2 + composeRule.onNodeWithText("olleh").assertIsDisplayed() + } +} diff --git a/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/WorkflowContainerTest.kt b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/WorkflowContainerTest.kt new file mode 100644 index 0000000000..85f1dedcaf --- /dev/null +++ b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/WorkflowContainerTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + +package com.squareup.workflow.ui.compose + +import androidx.compose.foundation.text.BasicText +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.ui.test.createComposeRule +import com.squareup.workflow.Workflow +import com.squareup.workflow.stateless +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewRegistry +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class WorkflowContainerTest { + + @Rule @JvmField val composeRule = createComposeRule() + + @Test fun rendersFromViewRegistry() { + val workflow = Workflow.stateless { "hello" } + val registry = ViewRegistry(composedViewFactory { rendering, _ -> + BasicText(rendering) + }) + + composeRule.setContent { + WorkflowContainer(workflow, ViewEnvironment(registry)) + } + + composeRule.onNodeWithText("hello").assertIsDisplayed() + } + + @Test fun automaticallyAddsComposeRenderingFactory() { + val workflow = Workflow.composed { _, _, _ -> + BasicText("it worked") + } + val registry = ViewRegistry() + + composeRule.setContent { + WorkflowContainer(workflow, ViewEnvironment(registry)) + } + + composeRule.onNodeWithText("it worked").assertIsDisplayed() + } +} diff --git a/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/internal/ViewFactoriesTest.kt b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/internal/ViewFactoriesTest.kt new file mode 100644 index 0000000000..abfb507072 --- /dev/null +++ b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/internal/ViewFactoriesTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.workflow.ui.compose.internal + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.ui.test.createComposeRule +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewFactory +import com.squareup.workflow.ui.ViewRegistry +import com.squareup.workflow.ui.bindShowRendering +import com.squareup.workflow.ui.compose.WorkflowRendering +import com.squareup.workflow.ui.compose.composedViewFactory +import com.squareup.workflow.ui.compose.withCompositionRoot +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ViewFactoriesTest { + + @Rule @JvmField val composeRule = createComposeRule() + + @Test fun WorkflowRendering_wrapsFactoryWithRoot_whenAlreadyInComposition() { + val viewEnvironment = ViewEnvironment(ViewRegistry(TestFactory)) + .withCompositionRoot { content -> + Column { + BasicText("one") + content() + } + } + + composeRule.setContent { + WorkflowRendering(TestRendering("two"), viewEnvironment) + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + } + + @Test fun WorkflowRendering_legacyAndroidViewRendersUpdates() { + val wrapperText = mutableStateOf("two") + val viewEnvironment = ViewEnvironment(ViewRegistry(LegacyViewViewFactory)) + + composeRule.setContent { + WorkflowRendering(LegacyViewRendering(wrapperText.value), viewEnvironment) + } + + onView(withText("two")).check(matches(isDisplayed())) + wrapperText.value = "OWT" + onView(withText("OWT")).check(matches(isDisplayed())) + } + + private data class TestRendering(val text: String) + private data class LegacyViewRendering(val text: String) + + private companion object { + val TestFactory = composedViewFactory { rendering, _ -> + BasicText(rendering.text) + } + val LegacyViewViewFactory = object : ViewFactory { + override val type = LegacyViewRendering::class + + override fun buildView( + initialRendering: LegacyViewRendering, + initialViewEnvironment: ViewEnvironment, + contextForNewView: Context, + container: ViewGroup? + ): View { + return TextView(contextForNewView).apply { + bindShowRendering(initialRendering, initialViewEnvironment) { rendering, _ -> + text = rendering.text + } + } + } + } + } +} diff --git a/compose/core-compose/src/main/AndroidManifest.xml b/compose/core-compose/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..4b26d2a703 --- /dev/null +++ b/compose/core-compose/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeRendering.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeRendering.kt new file mode 100644 index 0000000000..a90681cc4a --- /dev/null +++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeRendering.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + +package com.squareup.workflow.ui.compose + +import androidx.compose.runtime.Composable +import com.squareup.workflow.ui.compose.ComposeRendering.Companion.Factory +import com.squareup.workflow.ui.compose.ComposeRendering.Companion.NoopRendering +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewFactory + +/** + * A workflow rendering that renders itself using a [Composable] function. + * + * This is the rendering type of [ComposeWorkflow]. To stub out [ComposeWorkflow]s in `RenderTester` + * tests, use [NoopRendering]. + * + * To use this type, make sure your `ViewRegistry` registers [Factory]. + */ +class ComposeRendering internal constructor( + internal val render: @Composable (ViewEnvironment) -> Unit +) { + companion object { + /** + * A [ViewFactory] that renders a [ComposeRendering]. + */ + val Factory: ViewFactory = composedViewFactory { rendering, environment -> + rendering.render(environment) + } + + /** + * A [ComposeRendering] that doesn't do anything. Useful for unit testing. + */ + val NoopRendering = ComposeRendering {} + } +} diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeViewFactory.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeViewFactory.kt new file mode 100644 index 0000000000..95433884be --- /dev/null +++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeViewFactory.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2019 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// See https://youtrack.jetbrains.com/issue/KT-31734 +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + +package com.squareup.workflow.ui.compose + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ExperimentalComposeApi +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.setContent +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewFactory +import com.squareup.workflow.ui.bindShowRendering +import com.squareup.workflow.ui.compose.internal.ParentComposition +import kotlin.reflect.KClass + +/** + * Creates a [ViewFactory] that uses a [Composable] function to display the rendering. + * + * Note that the function you pass in will not have any `MaterialTheme` applied, so views that rely + * on Material theme attributes must be explicitly wrapped with `MaterialTheme`. + * + * Simple usage: + * + * ``` + * // Function references to @Composable functions aren't supported yet. + * val FooBinding = composedViewFactory { showFoo(it) } + * + * @Composable + * private fun showFoo(foo: FooRendering) { + * Text(foo.message) + * } + * + * … + * + * val viewRegistry = ViewRegistry(FooBinding, …) + * ``` + * + * ## Nesting child renderings + * + * Workflows can render other workflows, and renderings from one workflow can contain renderings + * from other workflows. These renderings may all be bound to their own [ViewFactory]s. Regular + * [ViewFactory]s and `LayoutRunner`s use + * [WorkflowViewStub][com.squareup.workflow.ui.WorkflowViewStub] to recursively show nested + * renderings using the [ViewRegistry][com.squareup.workflow.ui.ViewRegistry]. + * + * View factories defined using this function may also show nested renderings. Doing so is as simple + * as calling [WorkflowRendering] and passing in the nested rendering. See the kdoc on that function + * for an example. + * + * Nested renderings will have access to any ambients defined in outer composable, even if there are + * legacy views in between them, as long as the [ViewEnvironment] is propagated continuously between + * the two factories. + * + * ## Initializing Compose context + * + * Often all the [composedViewFactory] factories in an app need to share some context – for example, + * certain ambients need to be provided, such as `MaterialTheme`. To configure this shared context, + * call [withCompositionRoot] on your top-level [ViewEnvironment]. The first time a + * [composedViewFactory] is used to show a rendering, its [showRendering] function will be wrapped + * with the [CompositionRoot]. See the documentation on [CompositionRoot] for more information. + */ +inline fun composedViewFactory( + noinline showRendering: @Composable ( + rendering: RenderingT, + environment: ViewEnvironment + ) -> Unit +): ViewFactory = ComposeViewFactory(RenderingT::class, showRendering) + +@PublishedApi +internal class ComposeViewFactory( + override val type: KClass, + internal val content: @Composable (RenderingT, ViewEnvironment) -> Unit +) : ViewFactory { + + @OptIn(ExperimentalComposeApi::class) + override fun buildView( + initialRendering: RenderingT, + initialViewEnvironment: ViewEnvironment, + contextForNewView: Context, + container: ViewGroup? + ): View { + // There is currently no way to automatically generate an Android View directly from a + // Composable function, so we need to use ViewGroup.setContent. + val parentComposition = initialViewEnvironment[ParentComposition].reference + val composeContainer = FrameLayout(contextForNewView) + + if (parentComposition == null) { + // This composition will be the "root" – it must not be recomposed. + + val state = mutableStateOf(Pair(initialRendering, initialViewEnvironment)) + composeContainer.bindShowRendering( + initialRendering, + initialViewEnvironment + ) { rendering, environment -> + state.value = Pair(rendering, environment) + } + + composeContainer.setContent { + val (rendering, environment) = state.value + content(rendering, environment) + } + } else { + // This composition will be a subcomposition of another composition, we must recompose it + // manually every time something changes. This is not documented anywhere, but according to + // Compose devs it is part of the contract of subcomposition. + + // Update the state whenever a new rendering is emitted. + // This lambda will be executed synchronously before bindShowRendering returns. + composeContainer.bindShowRendering( + initialRendering, + initialViewEnvironment + ) { rendering, environment -> + // Entry point to the world of Compose. + composeContainer.setContent(parentComposition) { + content(rendering, environment) + } + } + } + + return composeContainer + } +} diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeWorkflow.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeWorkflow.kt new file mode 100644 index 0000000000..6d17a63a6c --- /dev/null +++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeWorkflow.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + +package com.squareup.workflow.ui.compose + +import androidx.compose.runtime.Composable +import com.squareup.workflow.Sink +import com.squareup.workflow.StatefulWorkflow +import com.squareup.workflow.Workflow +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.compose.internal.ComposeWorkflowImpl + +/** + * A stateless [Workflow][com.squareup.workflow.Workflow] that [renders][render] itself as + * [Composable] function. Effectively defines an inline + * [composedViewFactory][com.squareup.workflow.ui.compose.composedViewFactory]. + * + * This workflow does not have access to a [RenderContext][com.squareup.workflow.RenderContext] + * since render contexts are only valid during render passes, and this workflow's [render] method + * is invoked after the render pass, when view bindings are being shown. + * + * While this workflow is "stateless" in the usual workflow sense (it doesn't have a `StateT` type), + * since [render] is a Composable function, it can use all the usual Compose facilities for state + * management. + */ +abstract class ComposeWorkflow : + Workflow { + + /** + * Renders [props] using Compose. This function will be called to update the UI whenever the + * [props] or [viewEnvironment] changes. + * + * @param props The data to render. + * @param outputSink A [Sink] that can be used from UI event handlers to send outputs to this + * workflow's parent. + * @param viewEnvironment The [ViewEnvironment] passed down through the `ViewBinding` pipeline. + */ + @Composable abstract fun render( + props: PropsT, + outputSink: Sink, + viewEnvironment: ViewEnvironment + ) + + override fun asStatefulWorkflow(): StatefulWorkflow = + ComposeWorkflowImpl(this) +} + +/** + * Returns a [ComposeWorkflow] that renders itself using the given [render] function. + */ +inline fun Workflow.Companion.composed( + crossinline render: @Composable ( + props: PropsT, + outputSink: Sink, + environment: ViewEnvironment + ) -> Unit +): ComposeWorkflow = object : ComposeWorkflow() { + @Composable override fun render( + props: PropsT, + outputSink: Sink, + viewEnvironment: ViewEnvironment + ) { + render(props, outputSink, viewEnvironment) + } +} diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/CompositionRoot.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/CompositionRoot.kt new file mode 100644 index 0000000000..c1325e45ec --- /dev/null +++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/CompositionRoot.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + +package com.squareup.workflow.ui.compose + +import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.PRIVATE +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Providers +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticAmbientOf +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewRegistry +import com.squareup.workflow.ui.compose.internal.mapFactories + +/** + * Used by [wrapWithRootIfNecessary] to ensure the [CompositionRoot] is only applied once. + */ +private val HasViewFactoryRootBeenApplied = staticAmbientOf { false } + +/** + * A `@Composable` function that will be used to wrap the first (highest-level) + * [composedViewFactory] view factory in a composition. This can be used to setup any ambients that + * all [composedViewFactory] factories need access to, such as e.g. UI themes. + * + * This function will called once, to wrap the _highest-level_ [composedViewFactory] in the tree. + * However, ambients are propagated down to child [composedViewFactory] compositions, so any + * ambients provided here will be available in _all_ [composedViewFactory] compositions. + */ +typealias CompositionRoot = @Composable (content: @Composable () -> Unit) -> Unit + +/** + * Convenience function for applying a [CompositionRoot] to this [ViewEnvironment]'s [ViewRegistry]. + * See [ViewRegistry.withCompositionRoot]. + */ +fun ViewEnvironment.withCompositionRoot(root: CompositionRoot): ViewEnvironment = + this + (ViewRegistry to this[ViewRegistry].withCompositionRoot(root)) + +/** + * Returns a [ViewRegistry] that ensures that any [composedViewFactory] factories registered in this + * registry will be wrapped exactly once with a [CompositionRoot] wrapper. + * See [CompositionRoot] for more information. + */ +fun ViewRegistry.withCompositionRoot(root: CompositionRoot): ViewRegistry = + mapFactories { factory -> + @Suppress("UNCHECKED_CAST", "SafeCastWithReturn") + factory as? ComposeViewFactory ?: return@mapFactories factory + + @Suppress("UNCHECKED_CAST") + ComposeViewFactory(factory.type) { rendering, environment -> + wrapWithRootIfNecessary(root) { + factory.content(rendering, environment) + } + } + } + +/** + * Adds [content] to the composition, ensuring that [CompositionRoot] has been applied. Will only + * wrap the content at the highest occurrence of this function in the composition subtree. + */ +@VisibleForTesting(otherwise = PRIVATE) +@Composable internal fun wrapWithRootIfNecessary( + root: CompositionRoot, + content: @Composable () -> Unit +) { + if (HasViewFactoryRootBeenApplied.current) { + // The only way this ambient can have the value true is if, somewhere above this point in the + // composition, the else case below was hit and wrapped us in the ambient. Since the root + // wrapper will have already been applied, we can just compose content directly. + content() + } else { + // If the ambient is false, this is the first time this function has appeared in the composition + // so far. We provide a true value for the ambient for everything below us, so any recursive + // calls to this function will hit the if case above and not re-apply the wrapper. + Providers(HasViewFactoryRootBeenApplied provides true) { + val safeRoot: CompositionRoot = remember(root) { safeCompositionRoot(root) } + safeRoot(content) + } + } +} + +/** + * [CompositionRoot] that asserts that the content method invokes its children parameter + * exactly once, and throws an [IllegalStateException] if not. + */ +internal fun safeCompositionRoot(delegate: CompositionRoot): CompositionRoot = { content -> + var childrenCalledCount = 0 + delegate { + childrenCalledCount++ + content() + } + check(childrenCalledCount == 1) { + "Expected ComposableDecorator to invoke children exactly once, " + + "but was invoked $childrenCalledCount times." + } +} diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/RenderAsState.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/RenderAsState.kt new file mode 100644 index 0000000000..f94f08f8c6 --- /dev/null +++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/RenderAsState.kt @@ -0,0 +1,239 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("NOTHING_TO_INLINE") + +package com.squareup.workflow.ui.compose + +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLifecycleObserver +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.savedinstancestate.Saver +import androidx.compose.runtime.savedinstancestate.SaverScope +import androidx.compose.runtime.savedinstancestate.savedInstanceState +import androidx.compose.ui.node.Ref +import com.squareup.workflow.Snapshot +import com.squareup.workflow.Workflow +import com.squareup.workflow.diagnostic.WorkflowDiagnosticListener +import com.squareup.workflow.launchWorkflowIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.plus +import okio.ByteString + +/** + * Runs this [Workflow] as long as this composable is part of the composition, and returns a + * [State] object that will be updated whenever the runtime emits a new [RenderingT]. + * + * The workflow runtime will be started when this function is first added to the composition, and + * cancelled when it is removed. The first rendering will be available immediately as soon as this + * function returns, as [State.value]. Composables that read this value will automatically recompose + * whenever the runtime emits a new rendering. + * + * [Snapshot]s from the runtime will automatically be saved to the current + * [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is + * started, if a snapshot exists in the registry, it will be used to restore the workflows. + * + * @receiver The [Workflow] to run. If the value of the receiver changes to a different [Workflow] + * while this function is in the composition, the runtime will be restarted with the new workflow. + * @param props The [PropsT] for the root [Workflow]. Changes to this value across different + * compositions will cause the root workflow to re-render with the new props. + * @param onOutput A function that will be executed whenever the root [Workflow] emits an output. + * @param diagnosticListener An optional [WorkflowDiagnosticListener] to start the runtime with. If + * this value changes while this function is in the composition, the runtime will be restarted. + */ +@Composable +fun Workflow.renderAsState( + props: PropsT, + onOutput: (OutputT) -> Unit, + diagnosticListener: WorkflowDiagnosticListener? = null +): State = renderAsStateImpl(this, props, onOutput, diagnosticListener) + +/** + * Runs this [Workflow] as long as this composable is part of the composition, and returns a + * [State] object that will be updated whenever the runtime emits a new [RenderingT]. + * + * The workflow runtime will be started when this function is first added to the composition, and + * cancelled when it is removed. The first rendering will be available immediately as soon as this + * function returns, as [State.value]. Composables that read this value will automatically recompose + * whenever the runtime emits a new rendering. + * + * [Snapshot]s from the runtime will automatically be saved to the current + * [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is + * started, if a snapshot exists in the registry, it will be used to restore the workflows. + * + * @receiver The [Workflow] to run. If the value of the receiver changes to a different [Workflow] + * while this function is in the composition, the runtime will be restarted with the new workflow. + * @param onOutput A function that will be executed whenever the root [Workflow] emits an output. + * @param diagnosticListener An optional [WorkflowDiagnosticListener] to start the runtime with. If + * this value changes while this function is in the composition, the runtime will be restarted. + */ +@Composable +inline fun Workflow.renderAsState( + noinline onOutput: (OutputT) -> Unit, + diagnosticListener: WorkflowDiagnosticListener? = null +): State = renderAsState(Unit, onOutput, diagnosticListener) + +/** + * Runs this [Workflow] as long as this composable is part of the composition, and returns a + * [State] object that will be updated whenever the runtime emits a new [RenderingT]. + * + * The workflow runtime will be started when this function is first added to the composition, and + * cancelled when it is removed. The first rendering will be available immediately as soon as this + * function returns, as [State.value]. Composables that read this value will automatically recompose + * whenever the runtime emits a new rendering. + * + * [Snapshot]s from the runtime will automatically be saved to the current + * [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is + * started, if a snapshot exists in the registry, it will be used to restore the workflows. + * + * @receiver The [Workflow] to run. If the value of the receiver changes to a different [Workflow] + * while this function is in the composition, the runtime will be restarted with the new workflow. + * @param props The [PropsT] for the root [Workflow]. Changes to this value across different + * compositions will cause the root workflow to re-render with the new props. + * @param diagnosticListener An optional [WorkflowDiagnosticListener] to start the runtime with. If + * this value changes while this function is in the composition, the runtime will be restarted. + */ +@Composable +inline fun Workflow.renderAsState( + props: PropsT, + diagnosticListener: WorkflowDiagnosticListener? = null +): State = renderAsState(props, {}, diagnosticListener) + +/** + * Runs this [Workflow] as long as this composable is part of the composition, and returns a + * [State] object that will be updated whenever the runtime emits a new [RenderingT]. + * + * The workflow runtime will be started when this function is first added to the composition, and + * cancelled when it is removed. The first rendering will be available immediately as soon as this + * function returns, as [State.value]. Composables that read this value will automatically recompose + * whenever the runtime emits a new rendering. + * + * [Snapshot]s from the runtime will automatically be saved to the current + * [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is + * started, if a snapshot exists in the registry, it will be used to restore the workflows. + * + * @receiver The [Workflow] to run. If the value of the receiver changes to a different [Workflow] + * while this function is in the composition, the runtime will be restarted with the new workflow. + * @param diagnosticListener An optional [WorkflowDiagnosticListener] to start the runtime with. If + * this value changes while this function is in the composition, the runtime will be restarted. + */ +@Composable +inline fun Workflow.renderAsState( + diagnosticListener: WorkflowDiagnosticListener? = null +): State = renderAsState(Unit, {}, diagnosticListener) + +/** + * @param snapshotKey Allows tests to pass in a custom key to use to save/restore the snapshot from + * the [UiSavedStateRegistryAmbient]. If null, will use the default key based on source location. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +@Composable internal fun renderAsStateImpl( + workflow: Workflow, + props: PropsT, + onOutput: (OutputT) -> Unit, + diagnosticListener: WorkflowDiagnosticListener?, + snapshotKey: String? = null +): State { + // TODO Pass Dispatchers.Main.immediate and merge two scope vals when this bug is fixed: + // https://issuetracker.google.com/issues/165674304 + val baseScope = rememberCoroutineScope() + val workflowScope = remember { baseScope + Dispatchers.Main.immediate } + val snapshotState = savedInstanceState(key = snapshotKey, saver = SnapshotSaver) { null } + + val outputRef = remember { Ref<(OutputT) -> Unit>() } + outputRef.value = onOutput + + // We can't use onActive/on(Pre)Commit because they won't run their callback until after this + // function returns, and we need to run this immediately so we get the rendering synchronously. + val state = remember(workflow, diagnosticListener) { + WorkflowState(workflowScope, workflow, props, outputRef, snapshotState, diagnosticListener) + } + state.setProps(props) + + return state.rendering +} + +@Suppress("EXPERIMENTAL_API_USAGE") +private class WorkflowState( + private val workflowScope: CoroutineScope, + workflow: Workflow, + initialProps: PropsT, + private val outputRef: Ref<(OutputT) -> Unit>, + private val snapshotState: MutableState, + private val diagnosticListener: WorkflowDiagnosticListener? +) : CompositionLifecycleObserver { + + private val renderingState = mutableStateOf(null) + + // This can be a StateFlow once coroutines is upgraded to 1.3.6. + private val propsChannel = Channel(capacity = Channel.CONFLATED) + .apply { offer(initialProps) } + val propsFlow = propsChannel.consumeAsFlow() + .distinctUntilChanged() + + // The value is guaranteed to be set before returning, so this cast is fine. + @Suppress("UNCHECKED_CAST") + val rendering: State + get() = renderingState as State + + init { + launchWorkflowIn(workflowScope, workflow, propsFlow, snapshotState.value) { session -> + session.diagnosticListener = diagnosticListener + + session.outputs.onEach { outputRef.value!!.invoke(it) } + .launchIn(this) + + session.renderingsAndSnapshots + .onEach { (rendering, snapshot) -> + renderingState.value = rendering + snapshotState.value = snapshot + } + .launchIn(this) + } + } + + fun setProps(props: PropsT) { + propsChannel.offer(props) + } + + override fun onEnter() {} + + override fun onLeave() { + workflowScope.cancel() + } +} + +private object SnapshotSaver : Saver { + override fun SaverScope.save(value: Snapshot?): ByteArray { + return value?.bytes?.toByteArray() ?: ByteArray(0) + } + + override fun restore(value: ByteArray): Snapshot? { + return value.takeUnless { it.isEmpty() } + ?.let { bytes -> Snapshot.of(ByteString.of(*bytes)) } + } +} diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ViewEnvironments.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ViewEnvironments.kt new file mode 100644 index 0000000000..1847655083 --- /dev/null +++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ViewEnvironments.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.workflow.ui.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewRegistry +import com.squareup.workflow.ui.compose.internal.WorkflowRendering + +/** + * Renders [rendering] into the composition using this [ViewEnvironment]'s + * [ViewRegistry][com.squareup.workflow.ui.ViewRegistry] to generate the view. + * + * This function fulfills a similar role as + * [WorkflowViewStub][com.squareup.workflow.ui.WorkflowViewStub], but is much more convenient to use + * from Composable functions. + * + * ## Example + * + * ``` + * data class FramedRendering( + * val borderColor: Color, + * val child: Any + * ) + * + * val FramedContainerViewFactory = composedViewFactory { rendering, environment -> + * Surface(border = Border(rendering.borderColor, 8.dp)) { + * WorkflowRendering(rendering.child, environment) + * } + * } + * ``` + * + * @param rendering The workflow rendering to display. May be of any type for which a + * [ViewFactory][com.squareup.workflow.ui.ViewFactory] has been registered in this + * environment's [ViewRegistry]. + * @param modifier A [Modifier] that will be applied to composable used to show [rendering]. + * + * @throws IllegalArgumentException if no factory can be found for [rendering]'s type. + */ +@Composable fun WorkflowRendering( + rendering: Any, + viewEnvironment: ViewEnvironment, + modifier: Modifier = Modifier +) { + val viewRegistry = remember(viewEnvironment) { viewEnvironment[ViewRegistry] } + val renderingType = rendering::class + val viewFactory = remember(viewRegistry, renderingType) { + viewRegistry.getFactoryFor(renderingType) + } + WorkflowRendering(rendering, viewFactory, viewEnvironment, modifier) +} diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/WorkflowContainer.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/WorkflowContainer.kt new file mode 100644 index 0000000000..22edc2c864 --- /dev/null +++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/WorkflowContainer.kt @@ -0,0 +1,156 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress( + "FunctionNaming", + "NOTHING_TO_INLINE" +) + +package com.squareup.workflow.ui.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.squareup.workflow.Snapshot +import com.squareup.workflow.Workflow +import com.squareup.workflow.diagnostic.WorkflowDiagnosticListener +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewFactory +import com.squareup.workflow.ui.ViewRegistry +import com.squareup.workflow.ui.plus + +/** + * Render a [Workflow]'s renderings. + * + * When this function is first composed it will start a new runtime. This runtime will be restarted + * any time [workflow], [diagnosticListener], or the `CoroutineContext` + * changes. The runtime will be cancelled when this function stops composing. + * + * [Snapshot]s from the runtime will automatically be saved to the current + * [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is + * started, if a snapshot exists in the registry, it will be used to restore the workflows. + * + * @param workflow The [Workflow] to render. + * @param props The props to render the root workflow with. If this value changes between calls, + * the workflow runtime will re-render with the new props. + * @param onOutput A function that will be invoked any time the root workflow emits an output. + * @param viewEnvironment The [ViewEnvironment] used to display renderings. + * @param modifier The [Modifier] to apply to the root [ViewFactory]. + * @param diagnosticListener A [WorkflowDiagnosticListener] to configure on the runtime. + */ +@Composable fun WorkflowContainer( + workflow: Workflow, + props: PropsT, + onOutput: (OutputT) -> Unit, + viewEnvironment: ViewEnvironment, + modifier: Modifier = Modifier, + diagnosticListener: WorkflowDiagnosticListener? = null +) { + // Ensure ComposeRendering is in the ViewRegistry. + val realEnvironment = remember(viewEnvironment) { + viewEnvironment.withFactory(ComposeRendering.Factory) + } + + val rendering = workflow.renderAsState(props, onOutput, diagnosticListener) + WorkflowRendering(rendering.value, realEnvironment, modifier) +} + +/** + * Render a [Workflow]'s renderings. + * + * When this function is first composed it will start a new runtime. This runtime will be restarted + * any time [workflow], [diagnosticListener], or the `CoroutineContext` + * changes. The runtime will be cancelled when this function stops composing. + * + * [Snapshot]s from the runtime will automatically be saved to the current + * [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is + * started, if a snapshot exists in the registry, it will be used to restore the workflows. + * + * @param workflow The [Workflow] to render. + * @param onOutput A function that will be invoked any time the root workflow emits an output. + * @param viewEnvironment The [ViewEnvironment] used to display renderings. + * @param modifier The [Modifier] to apply to the root [ViewFactory]. + * @param diagnosticListener A [WorkflowDiagnosticListener] to configure on the runtime. + */ +@Composable inline fun WorkflowContainer( + workflow: Workflow, + noinline onOutput: (OutputT) -> Unit, + viewEnvironment: ViewEnvironment, + modifier: Modifier = Modifier, + diagnosticListener: WorkflowDiagnosticListener? = null +) { + WorkflowContainer(workflow, Unit, onOutput, viewEnvironment, modifier, diagnosticListener) +} + +/** + * Render a [Workflow]'s renderings. + * + * When this function is first composed it will start a new runtime. This runtime will be restarted + * any time [workflow], [diagnosticListener], or the `CoroutineContext` + * changes. The runtime will be cancelled when this function stops composing. + * + * [Snapshot]s from the runtime will automatically be saved to the current + * [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is + * started, if a snapshot exists in the registry, it will be used to restore the workflows. + * + * @param workflow The [Workflow] to render. + * @param props The props to render the root workflow with. If this value changes between calls, + * the workflow runtime will re-render with the new props. + * @param viewEnvironment The [ViewEnvironment] used to display renderings. + * @param modifier The [Modifier] to apply to the root [ViewFactory]. + * @param diagnosticListener A [WorkflowDiagnosticListener] to configure on the runtime. + */ +@Composable inline fun WorkflowContainer( + workflow: Workflow, + props: PropsT, + viewEnvironment: ViewEnvironment, + modifier: Modifier = Modifier, + diagnosticListener: WorkflowDiagnosticListener? = null +) { + WorkflowContainer(workflow, props, {}, viewEnvironment, modifier, diagnosticListener) +} + +/** + * Render a [Workflow]'s renderings. + * + * When this function is first composed it will start a new runtime. This runtime will be restarted + * any time [workflow], [diagnosticListener], or the `CoroutineContext` + * changes. The runtime will be cancelled when this function stops composing. + * + * [Snapshot]s from the runtime will automatically be saved to the current + * [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is + * started, if a snapshot exists in the registry, it will be used to restore the workflows. + * + * @param workflow The [Workflow] to render. + * @param viewEnvironment The [ViewEnvironment] used to display renderings. + * @param modifier The [Modifier] to apply to the root [ViewFactory]. + * @param diagnosticListener A [WorkflowDiagnosticListener] to configure on the runtime. + */ +@Composable inline fun WorkflowContainer( + workflow: Workflow, + viewEnvironment: ViewEnvironment, + modifier: Modifier = Modifier, + diagnosticListener: WorkflowDiagnosticListener? = null +) { + WorkflowContainer(workflow, Unit, {}, viewEnvironment, modifier, diagnosticListener) +} + +private fun ViewEnvironment.withFactory(viewFactory: ViewFactory<*>): ViewEnvironment { + return this[ViewRegistry].let { registry -> + if (viewFactory.type !in registry.keys) { + this + (ViewRegistry to registry + viewFactory) + } else this + } +} diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ComposeWorkflowImpl.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ComposeWorkflowImpl.kt new file mode 100644 index 0000000000..7e59a076a4 --- /dev/null +++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ComposeWorkflowImpl.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.workflow.ui.compose.internal + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.structuralEqualityPolicy +import com.squareup.workflow.RenderContext +import com.squareup.workflow.Sink +import com.squareup.workflow.Snapshot +import com.squareup.workflow.StatefulWorkflow +import com.squareup.workflow.action +import com.squareup.workflow.contraMap +import com.squareup.workflow.ui.compose.ComposeRendering +import com.squareup.workflow.ui.compose.ComposeWorkflow +import com.squareup.workflow.ui.compose.internal.ComposeWorkflowImpl.State + +internal class ComposeWorkflowImpl( + private val workflow: ComposeWorkflow +) : StatefulWorkflow, OutputT, ComposeRendering>() { + + // This doesn't need to be a @Model, it only gets set once, before the composable ever runs. + class SinkHolder(var sink: Sink? = null) + + data class State( + val propsHolder: MutableState, + val sinkHolder: SinkHolder, + val rendering: ComposeRendering + ) + + override fun initialState( + props: PropsT, + snapshot: Snapshot? + ): State { + val propsHolder = mutableStateOf(props, policy = structuralEqualityPolicy()) + val sinkHolder = SinkHolder() + return State(propsHolder, sinkHolder, ComposeRendering { environment -> + // The sink will get set on the first render pass, so it should never be null. + val sink = sinkHolder.sink!! + // Important: Use the props from the MutableState, _not_ the one passed into render. + workflow.render(propsHolder.value, sink, environment) + }) + } + + override fun onPropsChanged( + old: PropsT, + new: PropsT, + state: State + ): State { + state.propsHolder.value = new + return state + } + + override fun render( + props: PropsT, + state: State, + context: RenderContext, OutputT> + ): ComposeRendering { + // The first render pass needs to cache the sink. The sink is reusable, so we can just pass the + // same one every time. + if (state.sinkHolder.sink == null) { + state.sinkHolder.sink = context.actionSink.contraMap(::forwardOutput) + } + + // onPropsChanged will ensure the rendering is re-composed when the props changes. + return state.rendering + } + + // Compiler bug doesn't let us call Snapshot.EMPTY. + override fun snapshotState(state: State): Snapshot = Snapshot.of("") + + private fun forwardOutput(output: OutputT) = action { setOutput(output) } +} diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ParentComposition.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ParentComposition.kt new file mode 100644 index 0000000000..247ca15fc9 --- /dev/null +++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ParentComposition.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + +package com.squareup.workflow.ui.compose.internal + +import androidx.compose.runtime.CompositionReference +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewEnvironmentKey + +/** + * Holds a [CompositionReference] that can be passed to [setContent] to create a composition that is + * a child of another composition. Subcompositions get ambients and other compose context from their + * parent, and propagate invalidations, which allows ambients provided around a [WorkflowRendering] + * call to be read by nested Compose-based view factories. + * + * When [WorkflowRendering] is called, it will store an instance of this class in the + * [ViewEnvironment]. `ComposeViewFactory` pulls the reference out of the environment and uses it to + * link its composition to the outer one. + */ +internal class ParentComposition( + var reference: CompositionReference? = null +) { + companion object : ViewEnvironmentKey(ParentComposition::class) { + override val default: ParentComposition get() = ParentComposition() + } +} diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewFactories.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewFactories.kt new file mode 100644 index 0000000000..00d15ff42e --- /dev/null +++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewFactories.kt @@ -0,0 +1,182 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.workflow.ui.compose.internal + +import android.content.Context +import android.view.View +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.FrameLayout +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionReference +import androidx.compose.runtime.onCommit +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.node.Ref +import androidx.compose.ui.viewinterop.AndroidView +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewFactory +import com.squareup.workflow.ui.canShowRendering +import com.squareup.workflow.ui.compose.ComposeViewFactory +import com.squareup.workflow.ui.getRendering +import com.squareup.workflow.ui.showRendering +import kotlin.properties.Delegates.observable + +/** + * Renders [rendering] into the composition using [viewFactory]. + * + * To display a nested rendering from a + * [Composable view binding][com.squareup.workflow.ui.compose.composedViewFactory], use the overload + * without a [ViewFactory] parameter. + * + * *Note: [rendering] must be the same type as this [ViewFactory], even though the type system does + * not enforce this constraint. This is due to a Compose compiler bug tracked + * [here](https://issuetracker.google.com/issues/156527332). + * + * @see com.squareup.workflow.ui.compose.WorkflowRendering + */ +@Composable internal fun WorkflowRendering( + rendering: RenderingT, + viewFactory: ViewFactory, + viewEnvironment: ViewEnvironment, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + // "Fast" path: If the child binding is also a Composable, we don't need to go through the + // legacy view system and can just invoke the binding's composable function directly. + if (viewFactory is ComposeViewFactory) { + viewFactory.content(rendering, viewEnvironment) + return@Box + } + + // "Slow" path: Create a legacy Android View to show the rendering, like WorkflowViewStub. + ViewFactoryAndroidView(viewFactory, rendering, viewEnvironment) + } +} + +/** + * This is effectively the logic of [com.squareup.workflow.ui.WorkflowViewStub], but translated + * into Compose idioms. This approach has a few advantages: + * + * - Avoids extra custom views required to host `WorkflowViewStub` inside a Composition. Its trick + * of replacing itself in its parent doesn't play nice with Compose. + * - Allows us to pass the correct parent view for inflation (the root of the composition). + * - Avoids `WorkflowViewStub` having to do its own lookup to find the correct [ViewFactory], since + * we already have the correct one. + * + * Like `WorkflowViewStub`, this function uses the [viewFactory] to create and memoize a [View] to + * display the [rendering], keeps it updated with the latest [rendering] and [viewEnvironment], and + * adds it to the composition. + * + * This function also passes a [ParentComposition] down through the [ViewEnvironment] so that if the + * child view further nests any `ComposableViewFactory`s, they will be correctly subcomposed. + */ +@Composable private fun ViewFactoryAndroidView( + viewFactory: ViewFactory, + rendering: R, + viewEnvironment: ViewEnvironment +) { + // Plumb the current composition through the ViewEnvironment so any nested composable factories + // get access to any ambients currently in effect. + val parentComposition = remember { ParentComposition() } + parentComposition.reference = compositionReference() + val wrappedEnvironment = remember(viewEnvironment) { + viewEnvironment + (ParentComposition to parentComposition) + } + + // We can't trigger subcompositions during the composition itself, we have to wait until + // the composition is committed. So instead of sending the update in the AndroidView update + // lambda, we just store the view here, and then send the update and view factory in an + // onPreCommit hook. See https://github.com/square/workflow-kotlin-compose/issues/67. + val hostViewRef = remember { Ref() } + + AndroidView(::HostView) { + hostViewRef.value = it + } + + onCommit { + hostViewRef.value?.let { hostView -> + hostView.viewFactory = viewFactory + hostView.update = Pair(rendering, wrappedEnvironment) + } + } +} + +/** + * This is basically a clone of WorkflowViewStub, but it takes an explicit [ViewFactory] instead + * of looking one up itself, and doesn't do the replace-in-parent trick. + * + * It doesn't seem possible to create the view inside a Composable directly and use + * [androidx.ui.viewinterop.AndroidView]. I can't figure out exactly why it doesn't work, but I + * think it has something to do with getting into an incorrect state if a non-Composable view + * factory synchronously builds and binds a ComposableViewFactory in buildView. In that case, the + * second and subsequent compose passes will lose ambients from the parent composition. I've spent + * a bunch of time trying to debug compose internals and trying different approaches to figure out + * why that is, but nothing makes sense. All I know is that using a custom view like this seems to + * fix it. + * + * …Except in the case where the highest-level ComposableViewFactory isn't a subcomposition (i.e. + * the workflow is being ran with setContentWorkflow instead of WorkflowContainer). Or maybe it's + * only if the top-level ViewFactory is such a ComposableViewFactory, I haven't tested other legacy + * view factories between the root and the top-level CVF. In that case, there seems to be a race + * condition with measuring and second compose pass will throw an exception about an unmeasured + * node. + */ +private class HostView(context: Context) : FrameLayout(context) { + + private var rerender = true + private var view: View? = null + + var viewFactory by observable?>(null) { _, old, new -> + if (old != new) { + update() + } + } + + var update by observable?>(null) { _, old, new -> + if (old != new) { + rerender = true + update() + } + } + + init { + layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) + } + + private fun update() { + if (viewFactory == null) return + val (rendering, viewEnvironment) = update ?: return + + if (view?.canShowRendering(rendering) != true) { + // BuildView must call bindShowRendering, which will call showRendering. + @Suppress("UNCHECKED_CAST") + view = (viewFactory as ViewFactory) + .buildView(rendering, viewEnvironment, context, this) + + check(view!!.getRendering() != null) { + "View.bindShowRendering should have been called for $this, typically by the " + + "${ViewFactory::class.java.name} that created it." + } + removeAllViews() + addView(view) + } else if (rerender) { + view!!.showRendering(rendering, viewEnvironment) + } + + rerender = false + } +} diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewRegistries.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewRegistries.kt new file mode 100644 index 0000000000..8bec77e08f --- /dev/null +++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewRegistries.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.workflow.ui.compose.internal + +import com.squareup.workflow.ui.ViewFactory +import com.squareup.workflow.ui.ViewRegistry +import kotlin.reflect.KClass + +/** + * Applies [transform] to each [ViewFactory] in this registry. Transformations are applied lazily, + * at the time of lookup via [ViewRegistry.getFactoryFor]. + */ +internal fun ViewRegistry.mapFactories( + transform: (ViewFactory<*>) -> ViewFactory<*> +): ViewRegistry = object : ViewRegistry { + override val keys: Set> get() = this@mapFactories.keys + + override fun getFactoryFor( + renderingType: KClass + ): ViewFactory { + val transformedFactory = transform(this@mapFactories.getFactoryFor(renderingType)) + check(transformedFactory.type == renderingType) { + "Expected transform to return a ViewFactory that is compatible with $renderingType, " + + "but got one with type ${transformedFactory.type}" + } + @Suppress("UNCHECKED_CAST") + return transformedFactory as ViewFactory + } +} diff --git a/compose/detekt.yml b/compose/detekt.yml new file mode 100644 index 0000000000..51dc868162 --- /dev/null +++ b/compose/detekt.yml @@ -0,0 +1,20 @@ +# +# Copyright 2020 Square Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +naming: + MatchingDeclarationName: + active: false + FunctionNaming: + active: false diff --git a/compose/gradle.properties b/compose/gradle.properties new file mode 100644 index 0000000000..7b10a35508 --- /dev/null +++ b/compose/gradle.properties @@ -0,0 +1,40 @@ +# +# Copyright 2017 Square Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +org.gradle.jvmargs='-Dfile.encoding=UTF-8' +org.gradle.parallel=true +android.useAndroidX=true +# Required for ViewBinding. +android.enableJetifier=true + +# Required to publish to Nexus (see https://github.com/gradle/gradle/issues/11308) +systemProp.org.gradle.internal.publish.checksums.insecure=true + +GROUP=com.squareup.workflow +VERSION_NAME=0.31.0-SNAPSHOT + +POM_DESCRIPTION=Reactive workflows + +POM_URL=https://github.com/square/workflow/ +POM_SCM_URL=https://github.com/square/workflow/ +POM_SCM_CONNECTION=scm:git:git://github.com/square/workflow.git +POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/square/workflow.git + +POM_LICENCE_NAME=The Apache Software License, Version 2.0 +POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt +POM_LICENCE_DIST=repo + +POM_DEVELOPER_ID=square +POM_DEVELOPER_NAME=Square, Inc. diff --git a/compose/gradle/wrapper/gradle-wrapper.jar b/compose/gradle/wrapper/gradle-wrapper.jar new file mode 100755 index 0000000000..e708b1c023 Binary files /dev/null and b/compose/gradle/wrapper/gradle-wrapper.jar differ diff --git a/compose/gradle/wrapper/gradle-wrapper.properties b/compose/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..14e30f7416 --- /dev/null +++ b/compose/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/compose/gradlew b/compose/gradlew new file mode 100755 index 0000000000..4f906e0c81 --- /dev/null +++ b/compose/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/compose/gradlew.bat b/compose/gradlew.bat new file mode 100644 index 0000000000..ac1b06f938 --- /dev/null +++ b/compose/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/compose/samples/build.gradle.kts b/compose/samples/build.gradle.kts new file mode 100644 index 0000000000..d5f143cbe0 --- /dev/null +++ b/compose/samples/build.gradle.kts @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("com.android.application") + kotlin("android") +} + +apply(from = rootProject.file(".buildscript/configure-android-defaults.gradle")) +apply(from = rootProject.file(".buildscript/android-sample-app.gradle")) +apply(from = rootProject.file(".buildscript/android-ui-tests.gradle")) + +android { + defaultConfig { + applicationId = "com.squareup.sample" + } +} + +apply(from = rootProject.file(".buildscript/configure-compose.gradle")) + +dependencies { + implementation(project(":core-compose")) + implementation(Dependencies.AndroidX.appcompat) + implementation(Dependencies.Compose.layout) + implementation(Dependencies.Compose.material) + implementation(Dependencies.Compose.foundation) + implementation(Dependencies.Workflow.core) + implementation(Dependencies.Workflow.runtime) + implementation(Dependencies.Workflow.UI.coreAndroid) + + debugImplementation(project(":compose-tooling")) +} diff --git a/compose/samples/src/androidTest/java/com/squareup/sample/hellocomposebinding/HelloBindingTest.kt b/compose/samples/src/androidTest/java/com/squareup/sample/hellocomposebinding/HelloBindingTest.kt new file mode 100644 index 0000000000..8359c3cc97 --- /dev/null +++ b/compose/samples/src/androidTest/java/com/squareup/sample/hellocomposebinding/HelloBindingTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.hellocomposebinding + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HelloBindingTest { + + // Launches the activity. + @Rule @JvmField val composeRule = createAndroidComposeRule() + + @Test fun togglesBetweenStates() { + composeRule.onNodeWithText("Hello") + .assertIsDisplayed() + .performClick() + composeRule.onNodeWithText("Goodbye") + .assertIsDisplayed() + .performClick() + composeRule.onNodeWithText("Hello") + .assertIsDisplayed() + } +} diff --git a/compose/samples/src/androidTest/java/com/squareup/sample/hellocomposerendering/HelloComposeRenderingTest.kt b/compose/samples/src/androidTest/java/com/squareup/sample/hellocomposerendering/HelloComposeRenderingTest.kt new file mode 100644 index 0000000000..c3a5b3373a --- /dev/null +++ b/compose/samples/src/androidTest/java/com/squareup/sample/hellocomposerendering/HelloComposeRenderingTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.hellocomposerendering + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HelloComposeRenderingTest { + + // Launches the activity. + @Rule @JvmField val composeRule = createAndroidComposeRule() + + @Test fun togglesBetweenStates() { + composeRule.onNodeWithText("Hello") + .assertIsDisplayed() + .performClick() + composeRule.onNodeWithText("Goodbye") + .assertIsDisplayed() + .performClick() + composeRule.onNodeWithText("Hello") + .assertIsDisplayed() + } +} diff --git a/compose/samples/src/androidTest/java/com/squareup/sample/launcher/SampleLauncherTest.kt b/compose/samples/src/androidTest/java/com/squareup/sample/launcher/SampleLauncherTest.kt new file mode 100644 index 0000000000..12f7e450c4 --- /dev/null +++ b/compose/samples/src/androidTest/java/com/squareup/sample/launcher/SampleLauncherTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.launcher + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.espresso.Espresso.pressBack +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.squareup.sample.R +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SampleLauncherTest { + + @Rule @JvmField val composeRule = createAndroidComposeRule() + + @Test fun allSamplesLaunch() { + val appName = + InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.app_name) + composeRule.onNodeWithText(appName).assertIsDisplayed() + + samples.forEach { sample -> + try { + composeRule.onNodeWithText(sample.description, useUnmergedTree = true) + .performClick() + pressBack() + } catch (e: Throwable) { + throw AssertionError("Failed to launch sample ${sample.name}", e) + } + } + } +} diff --git a/compose/samples/src/androidTest/java/com/squareup/sample/nestedrenderings/NestedRenderingsTest.kt b/compose/samples/src/androidTest/java/com/squareup/sample/nestedrenderings/NestedRenderingsTest.kt new file mode 100644 index 0000000000..c24dce9b29 --- /dev/null +++ b/compose/samples/src/androidTest/java/com/squareup/sample/nestedrenderings/NestedRenderingsTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.nestedrenderings + +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.SemanticsNodeInteractionCollection +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +private const val ADD_BUTTON_TEXT = "Add Child" + +@RunWith(AndroidJUnit4::class) +class NestedRenderingsTest { + + // Launches the activity. + @Rule @JvmField val composeRule = createAndroidComposeRule() + + @Test fun childrenAreAddedAndRemoved() { + composeRule.onNodeWithText(ADD_BUTTON_TEXT) + .assertIsDisplayed() + .performClick() + + composeRule.onAllNodesWithText(ADD_BUTTON_TEXT) + .assertCountEquals(2) + .forEach { it.performClick() } + + composeRule.onAllNodesWithText(ADD_BUTTON_TEXT) + .assertCountEquals(4) + + resetAll() + composeRule.onAllNodesWithText(ADD_BUTTON_TEXT).assertCountEquals(1) + } + + /** + * We can't rely on the order of nodes returned by [onAllNodesWithText], and the contents of the + * collection will change as we remove nodes, so we have to double-loop over all reset buttons and + * click them all until there is only one left. + */ + private fun resetAll() { + var foundNodes = Int.MAX_VALUE + while (foundNodes > 1) { + foundNodes = 0 + composeRule.onAllNodesWithText("Reset").forEach { + try { + it.assertExists() + } catch (e: AssertionError) { + // No more reset buttons, we're done. + return@forEach + } + foundNodes++ + it.performClick() + } + } + } + + private fun SemanticsNodeInteractionCollection.forEach( + block: (SemanticsNodeInteraction) -> Unit + ) { + val count = fetchSemanticsNodes().size + for (i in 0 until count) block(get(i)) + } +} diff --git a/compose/samples/src/main/AndroidManifest.xml b/compose/samples/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..881150216a --- /dev/null +++ b/compose/samples/src/main/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocompose/App.kt b/compose/samples/src/main/java/com/squareup/sample/hellocompose/App.kt new file mode 100644 index 0000000000..8b54d4efd2 --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/hellocompose/App.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.hellocompose + +import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.ui.tooling.preview.Preview +import com.squareup.workflow.diagnostic.SimpleLoggingDiagnosticListener +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewRegistry +import com.squareup.workflow.ui.compose.WorkflowContainer + +private val viewRegistry = ViewRegistry(HelloBinding) +private val viewEnvironment = ViewEnvironment(viewRegistry) + +@Composable fun App() { + MaterialTheme { + WorkflowContainer( + HelloWorkflow, viewEnvironment, + modifier = Modifier.border( + shape = RoundedCornerShape(10.dp), + width = 10.dp, + color = Color.Magenta + ), + diagnosticListener = SimpleLoggingDiagnosticListener() + ) + } +} + +@Preview(showBackground = true) +@Composable private fun AppPreview() { + App() +} diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloBinding.kt b/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloBinding.kt new file mode 100644 index 0000000000..7b7dd6b8d0 --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloBinding.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.hellocompose + +import androidx.compose.material.Text +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.squareup.sample.hellocompose.HelloWorkflow.Rendering +import com.squareup.workflow.ui.compose.composedViewFactory + +val HelloBinding = composedViewFactory { rendering, _ -> + Text( + rendering.message, + modifier = Modifier + .clickable(onClick = rendering.onClick) + .fillMaxSize() + .wrapContentSize(Alignment.Center) + ) +} diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloComposeActivity.kt b/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloComposeActivity.kt new file mode 100644 index 0000000000..67614a2da9 --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloComposeActivity.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.hellocompose + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.platform.setContent + +class HelloComposeActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + App() + } + } +} diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloWorkflow.kt b/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloWorkflow.kt new file mode 100644 index 0000000000..46dfbfdec3 --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloWorkflow.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.hellocompose + +import com.squareup.sample.hellocompose.HelloWorkflow.Rendering +import com.squareup.sample.hellocompose.HelloWorkflow.State +import com.squareup.sample.hellocompose.HelloWorkflow.State.Goodbye +import com.squareup.sample.hellocompose.HelloWorkflow.State.Hello +import com.squareup.workflow.RenderContext +import com.squareup.workflow.Snapshot +import com.squareup.workflow.StatefulWorkflow +import com.squareup.workflow.action +import com.squareup.workflow.parse + +object HelloWorkflow : StatefulWorkflow() { + enum class State { + Hello, + Goodbye; + + fun theOtherState(): State = when (this) { + Hello -> Goodbye + Goodbye -> Hello + } + } + + data class Rendering( + val message: String, + val onClick: () -> Unit + ) + + private val helloAction = action { + nextState = nextState.theOtherState() + } + + override fun initialState( + props: Unit, + snapshot: Snapshot? + ): State = snapshot?.bytes?.parse { source -> if (source.readInt() == 1) Hello else Goodbye } + ?: Hello + + override fun render( + props: Unit, + state: State, + context: RenderContext + ): Rendering = Rendering( + message = state.name, + onClick = { context.actionSink.send(helloAction) } + ) + + override fun snapshotState(state: State): Snapshot = Snapshot.of(if (state == Hello) 1 else 0) +} diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloBinding.kt b/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloBinding.kt new file mode 100644 index 0000000000..9119e0b504 --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloBinding.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2019 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.hellocomposebinding + +import androidx.compose.material.Text +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.ui.tooling.preview.Preview +import com.squareup.sample.hellocomposebinding.HelloWorkflow.Rendering +import com.squareup.workflow.ui.compose.composedViewFactory +import com.squareup.workflow.ui.compose.tooling.preview + +val HelloBinding = composedViewFactory { rendering, _ -> + Text( + rendering.message, + modifier = Modifier.fillMaxSize() + .clickable(onClick = rendering.onClick) + .wrapContentSize(Alignment.Center) + ) +} + +@Preview(heightDp = 150, showBackground = true) +@Composable fun DrawHelloRenderingPreview() { + HelloBinding.preview(Rendering("Hello!", onClick = {})) +} diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloBindingActivity.kt b/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloBindingActivity.kt new file mode 100644 index 0000000000..798e7a5eb2 --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloBindingActivity.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2019 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.hellocomposebinding + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material.MaterialTheme +import com.squareup.workflow.diagnostic.SimpleLoggingDiagnosticListener +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewRegistry +import com.squareup.workflow.ui.WorkflowRunner +import com.squareup.workflow.ui.compose.withCompositionRoot +import com.squareup.workflow.ui.setContentWorkflow + +private val viewRegistry = ViewRegistry(HelloBinding) +private val containerHints = ViewEnvironment(viewRegistry).withCompositionRoot { content -> + MaterialTheme(content = content) +} + +class HelloBindingActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentWorkflow(containerHints) { + WorkflowRunner.Config( + HelloWorkflow, + diagnosticListener = SimpleLoggingDiagnosticListener() + ) + } + } +} diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloWorkflow.kt b/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloWorkflow.kt new file mode 100644 index 0000000000..cafa517d24 --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloWorkflow.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2019 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.hellocomposebinding + +import com.squareup.sample.hellocomposebinding.HelloWorkflow.Rendering +import com.squareup.sample.hellocomposebinding.HelloWorkflow.State +import com.squareup.sample.hellocomposebinding.HelloWorkflow.State.Goodbye +import com.squareup.sample.hellocomposebinding.HelloWorkflow.State.Hello +import com.squareup.workflow.RenderContext +import com.squareup.workflow.Snapshot +import com.squareup.workflow.StatefulWorkflow +import com.squareup.workflow.action +import com.squareup.workflow.parse + +object HelloWorkflow : StatefulWorkflow() { + enum class State { + Hello, + Goodbye; + + fun theOtherState(): State = when (this) { + Hello -> Goodbye + Goodbye -> Hello + } + } + + data class Rendering( + val message: String, + val onClick: () -> Unit + ) + + private val helloAction = action { + nextState = nextState.theOtherState() + } + + override fun initialState( + props: Unit, + snapshot: Snapshot? + ): State = snapshot?.bytes?.parse { source -> if (source.readInt() == 1) Hello else Goodbye } + ?: Hello + + override fun render( + props: Unit, + state: State, + context: RenderContext + ): Rendering { + return Rendering( + message = state.name, + onClick = { context.actionSink.send(helloAction) } + ) + } + + override fun snapshotState(state: State): Snapshot = Snapshot.of(if (state == Hello) 1 else 0) +} diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloComposeRenderingActivity.kt b/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloComposeRenderingActivity.kt new file mode 100644 index 0000000000..5c6e08319d --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloComposeRenderingActivity.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.hellocomposerendering + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.squareup.workflow.ui.compose.ComposeRendering +import com.squareup.workflow.diagnostic.SimpleLoggingDiagnosticListener +import com.squareup.workflow.ui.ViewRegistry +import com.squareup.workflow.ui.WorkflowRunner +import com.squareup.workflow.ui.setContentWorkflow + +private val viewRegistry = ViewRegistry(ComposeRendering.Factory) + +class HelloComposeRenderingActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentWorkflow(viewRegistry) { + WorkflowRunner.Config( + HelloWorkflow, + diagnosticListener = SimpleLoggingDiagnosticListener() + ) + } + } +} diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloRenderingWorkflow.kt b/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloRenderingWorkflow.kt new file mode 100644 index 0000000000..8d7b794c73 --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloRenderingWorkflow.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.hellocomposerendering + +import androidx.compose.material.Text +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.ui.tooling.preview.Preview +import com.squareup.sample.hellocomposerendering.HelloRenderingWorkflow.Toggle +import com.squareup.workflow.Sink +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.compose.ComposeWorkflow +import com.squareup.workflow.ui.compose.tooling.preview + +/** + * A [ComposeWorkflow] that is used by [HelloWorkflow] to render the screen. + * + * This workflow has type `Workflow`. + */ +object HelloRenderingWorkflow : ComposeWorkflow() { + + object Toggle + + @Composable override fun render( + props: String, + outputSink: Sink, + viewEnvironment: ViewEnvironment + ) { + MaterialTheme { + Text( + props, + modifier = Modifier + .clickable(onClick = { outputSink.send(Toggle) }) + .fillMaxSize() + .wrapContentSize(Alignment.Center) + ) + } + } +} + +@Preview(showBackground = true) +@Composable fun HelloRenderingWorkflowPreview() { + HelloRenderingWorkflow.preview(props = "hello") +} diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloWorkflow.kt b/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloWorkflow.kt new file mode 100644 index 0000000000..8958af5891 --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloWorkflow.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.hellocomposerendering + +import com.squareup.sample.hellocomposerendering.HelloWorkflow.State +import com.squareup.sample.hellocomposerendering.HelloWorkflow.State.Goodbye +import com.squareup.sample.hellocomposerendering.HelloWorkflow.State.Hello +import com.squareup.workflow.RenderContext +import com.squareup.workflow.Snapshot +import com.squareup.workflow.StatefulWorkflow +import com.squareup.workflow.action +import com.squareup.workflow.parse +import com.squareup.workflow.ui.compose.ComposeRendering + +object HelloWorkflow : StatefulWorkflow() { + enum class State { + Hello, + Goodbye; + + fun theOtherState(): State = when (this) { + Hello -> Goodbye + Goodbye -> Hello + } + } + + private val helloAction = action { + nextState = nextState.theOtherState() + } + + override fun initialState( + props: Unit, + snapshot: Snapshot? + ): State = snapshot?.bytes?.parse { source -> if (source.readInt() == 1) Hello else Goodbye } + ?: Hello + + override fun render( + props: Unit, + state: State, + context: RenderContext + ): ComposeRendering = + context.renderChild(HelloRenderingWorkflow, state.name) { helloAction } + + override fun snapshotState(state: State): Snapshot = Snapshot.of(if (state == Hello) 1 else 0) +} diff --git a/compose/samples/src/main/java/com/squareup/sample/launcher/SampleLauncherActivity.kt b/compose/samples/src/main/java/com/squareup/sample/launcher/SampleLauncherActivity.kt new file mode 100644 index 0000000000..5ad90430aa --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/launcher/SampleLauncherActivity.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.launcher + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.platform.setContent + +class SampleLauncherActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + SampleLauncherApp() + } + } +} diff --git a/compose/samples/src/main/java/com/squareup/sample/launcher/SampleLauncherApp.kt b/compose/samples/src/main/java/com/squareup/sample/launcher/SampleLauncherApp.kt new file mode 100644 index 0000000000..6800aae5c9 --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/launcher/SampleLauncherApp.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.launcher + +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumnFor +import androidx.compose.material.ListItem +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.drawLayer +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.gesture.rawPressStartGestureFilter +import androidx.compose.ui.input.pointer.PointerEventPass.Initial +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.globalBounds +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.node.Ref +import androidx.compose.ui.platform.ConfigurationAmbient +import androidx.compose.ui.platform.ViewAmbient +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityOptionsCompat.makeScaleUpAnimation +import androidx.core.content.ContextCompat.startActivity +import androidx.ui.tooling.preview.Preview +import com.squareup.sample.R.string + +@Composable fun SampleLauncherApp() { + MaterialTheme(colors = darkColors()) { + Scaffold( + topBar = { + TopAppBar(title = { + Text(stringResource(string.app_name)) + }) + } + ) { + LazyColumnFor(samples) { sample -> + SampleItem(sample) + } + } + } +} + +@Preview @Composable private fun SampleLauncherAppPreview() { + SampleLauncherApp() +} + +@Composable private fun SampleItem(sample: Sample) { + val rootView = ViewAmbient.current + + /** + * [androidx.compose.ui.layout.LayoutCoordinates.globalBounds] corresponds to the coordinates in + * the root Android view hosting the composition. + */ + val globalBounds = remember { Ref() } + + ListItem( + text = { Text(sample.name) }, + secondaryText = { Text(sample.description) }, + singleLineSecondaryText = false, + // Animate the activities as scaling up from where the preview is drawn. + icon = { SamplePreview(sample) { globalBounds.value = it.globalBounds } }, + modifier = Modifier.clickable { launchSample(sample, rootView, globalBounds.value) } + ) +} + +@Composable private fun SamplePreview( + sample: Sample, + onPreviewCoordinates: (LayoutCoordinates) -> Unit +) { + val configuration = ConfigurationAmbient.current + val screenRatio = configuration.screenWidthDp.toFloat() / configuration.screenHeightDp.toFloat() + // 88dp is taken from ListItem implementation. This doesn't seem to be coming in via any + // constraints as of dev11. + val previewHeight = 88.dp - 16.dp + val scale = previewHeight / configuration.screenHeightDp.dp + + // Force the previews to the scaled size, with the aspect ratio of the device. + // This is needed because the inner Box measures the previews at maximum size, so we have to clamp + // the measurements here otherwise the rest of the UI will think the previews are full-size even + // though they're graphically scaled down. + Box( + modifier = Modifier + .height(previewHeight) + .aspectRatio(screenRatio) + .onGloballyPositioned(onPreviewCoordinates) + ) { + // Preview the samples with a light theme, since that's what most of them use. + MaterialTheme(lightColors()) { + Surface { + Box( + modifier = Modifier + // Disable touch input, since this preview isn't meant to be interactive. + .rawPressStartGestureFilter( + enabled = true, executionPass = Initial, onPressStart = {} + ) + // Measure/layout the child at full screen size, and then just scale the pixels + // down. This way all the text and other density-dependent things get scaled + // correctly too. + .height(configuration.screenHeightDp.dp) + .width(configuration.screenWidthDp.dp) + .drawLayer(scaleX = scale, scaleY = scale) + ) { + sample.preview() + } + } + } + } +} + +private fun launchSample( + sample: Sample, + rootView: View, + sourceBounds: Rect? +) { + val context = rootView.context + val intent = Intent(context, sample.activityClass.java) + val options: Bundle? = sourceBounds?.let { + makeScaleUpAnimation( + rootView, + it.left.toInt(), + it.top.toInt(), + it.width.toInt(), + it.height.toInt() + ).toBundle() + } + startActivity(context, intent, options) +} diff --git a/compose/samples/src/main/java/com/squareup/sample/launcher/Samples.kt b/compose/samples/src/main/java/com/squareup/sample/launcher/Samples.kt new file mode 100644 index 0000000000..80f6617bc8 --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/launcher/Samples.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + +package com.squareup.sample.launcher + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import com.squareup.sample.hellocompose.App +import com.squareup.sample.hellocompose.HelloComposeActivity +import com.squareup.sample.hellocomposebinding.DrawHelloRenderingPreview +import com.squareup.sample.hellocomposebinding.HelloBindingActivity +import com.squareup.sample.hellocomposerendering.HelloComposeRenderingActivity +import com.squareup.sample.hellocomposerendering.HelloRenderingWorkflowPreview +import com.squareup.sample.nestedrenderings.NestedRenderingsActivity +import com.squareup.sample.nestedrenderings.RecursiveViewFactoryPreview +import com.squareup.sample.textinput.TextInputActivity +import com.squareup.sample.textinput.TextInputAppPreview +import kotlin.reflect.KClass + +val samples = listOf( + Sample( + "Hello Compose Binding", HelloBindingActivity::class, + "Creates a ViewFactory using bindCompose." + ) { DrawHelloRenderingPreview() }, + Sample( + "Hello Compose Rendering", HelloComposeRenderingActivity::class, + "Uses ComposeWorkflow to create a workflow that draws itself." + ) { HelloRenderingWorkflowPreview() }, + Sample( + "Hello Compose", HelloComposeActivity::class, + "A pure Compose app that launches its root Workflow from inside Compose." + ) { App() }, + Sample( + "Nested Renderings", NestedRenderingsActivity::class, + "Demonstrates recursive view factories using both Compose and legacy view factories." + ) { RecursiveViewFactoryPreview() }, + Sample( + "Text Input", TextInputActivity::class, + "Demonstrates a workflow that drives a TextField." + ) { TextInputAppPreview() } +) + +data class Sample( + val name: String, + val activityClass: KClass, + val description: String, + val preview: @Composable () -> Unit +) diff --git a/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/LegacyRunner.kt b/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/LegacyRunner.kt new file mode 100644 index 0000000000..e898a467b5 --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/LegacyRunner.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.nestedrenderings + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.ui.tooling.preview.Preview +import com.squareup.sample.databinding.LegacyViewBinding +import com.squareup.sample.nestedrenderings.RecursiveWorkflow.LegacyRendering +import com.squareup.workflow.ui.LayoutRunner +import com.squareup.workflow.ui.LayoutRunner.Companion.bind +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewFactory +import com.squareup.workflow.ui.compose.tooling.preview + +/** + * A [LayoutRunner] that renders [LegacyRendering]s using the legacy view framework. + */ +class LegacyRunner(private val binding: LegacyViewBinding) : LayoutRunner { + + override fun showRendering( + rendering: LegacyRendering, + viewEnvironment: ViewEnvironment + ) { + binding.stub.update(rendering.rendering, viewEnvironment) + } + + companion object : ViewFactory by bind( + LegacyViewBinding::inflate, ::LegacyRunner + ) +} + +@Preview(widthDp = 200, heightDp = 150, showBackground = true) +@Composable private fun LegacyRunnerPreview() { + LegacyRunner.preview( + rendering = LegacyRendering("child"), + placeholderModifier = Modifier.fillMaxSize() + ) +} diff --git a/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/NestedRenderingsActivity.kt b/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/NestedRenderingsActivity.kt new file mode 100644 index 0000000000..c624c2e500 --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/NestedRenderingsActivity.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.nestedrenderings + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.Providers +import androidx.compose.ui.graphics.Color +import com.squareup.workflow.diagnostic.SimpleLoggingDiagnosticListener +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewRegistry +import com.squareup.workflow.ui.WorkflowRunner +import com.squareup.workflow.ui.compose.withCompositionRoot +import com.squareup.workflow.ui.setContentWorkflow + +private val viewRegistry = ViewRegistry( + RecursiveViewFactory, + LegacyRunner +) + +private val viewEnvironment = ViewEnvironment(viewRegistry).withCompositionRoot { content -> + Providers(BackgroundColorAmbient provides Color.Green, children = content) +} + +class NestedRenderingsActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentWorkflow(viewEnvironment) { + WorkflowRunner.Config( + RecursiveWorkflow, + diagnosticListener = SimpleLoggingDiagnosticListener() + ) + } + } +} diff --git a/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/RecursiveViewFactory.kt b/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/RecursiveViewFactory.kt new file mode 100644 index 0000000000..e6a47c5ccc --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/RecursiveViewFactory.kt @@ -0,0 +1,144 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + +package com.squareup.sample.nestedrenderings + +import androidx.compose.material.Text +import androidx.compose.foundation.layout.Arrangement.SpaceEvenly +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayout +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.MainAxisAlignment +import androidx.compose.foundation.layout.SizeMode.Expand +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.Card +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Providers +import androidx.compose.runtime.ambientOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.res.dimensionResource +import androidx.ui.tooling.preview.Preview +import com.squareup.sample.R +import com.squareup.sample.nestedrenderings.RecursiveWorkflow.Rendering +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.compose.WorkflowRendering +import com.squareup.workflow.ui.compose.composedViewFactory +import com.squareup.workflow.ui.compose.tooling.preview + +/** + * Ambient of [Color] to use as the background color for a [RecursiveViewFactory]. + */ +val BackgroundColorAmbient = ambientOf { error("No background color specified") } + +/** + * A `ViewFactory` that renders [RecursiveWorkflow.Rendering]s. + */ +val RecursiveViewFactory = composedViewFactory { rendering, viewEnvironment -> + // Every child should be drawn with a slightly-darker background color. + val color = BackgroundColorAmbient.current + val childColor = remember(color) { + color.copy(alpha = .9f) + .compositeOver(Color.Black) + } + + Card(backgroundColor = color) { + Column( + Modifier.padding(dimensionResource(R.dimen.recursive_padding)) + .fillMaxSize(), + horizontalAlignment = CenterHorizontally + ) { + Providers(BackgroundColorAmbient provides childColor) { + Children( + rendering.children, viewEnvironment, + // Pass a weight so that the column fills all the space not occupied by the buttons. + modifier = Modifier.weight(1f, fill = true) + ) + } + Buttons( + onAdd = rendering.onAddChildClicked, + onReset = rendering.onResetClicked + ) + } + } +} + +@Preview +@Composable fun RecursiveViewFactoryPreview() { + Providers(BackgroundColorAmbient provides Color.Green) { + RecursiveViewFactory.preview( + Rendering( + children = listOf( + "foo", + Rendering( + children = listOf("bar"), + onAddChildClicked = {}, onResetClicked = {} + ) + ), onAddChildClicked = {}, onResetClicked = {} + ), + placeholderModifier = Modifier.fillMaxSize() + ) + } +} + +@Composable private fun Children( + children: List, + viewEnvironment: ViewEnvironment, + modifier: Modifier +) { + Column( + modifier = modifier, + verticalArrangement = SpaceEvenly, + horizontalAlignment = CenterHorizontally + ) { + children.forEach { childRendering -> + WorkflowRendering( + childRendering, + // Pass a weight so all children are partitioned evenly within the total column space. + // Without the weight, each child is the full size of the parent. + viewEnvironment, + modifier = Modifier.weight(1f, true) + .padding(dimensionResource(R.dimen.recursive_padding)) + ) + } + } +} + +@OptIn(ExperimentalLayout::class) +@Composable private fun Buttons( + onAdd: () -> Unit, + onReset: () -> Unit +) { + // Use a FlowRow so the buttons will wrap when the parent is too narrow. + FlowRow( + mainAxisSize = Expand, + mainAxisAlignment = MainAxisAlignment.SpaceEvenly, + crossAxisSpacing = dimensionResource(R.dimen.recursive_padding) + ) { + Button(onClick = onAdd) { + Text("Add Child") + } + Button(onClick = onReset) { + Text("Reset") + } + } +} diff --git a/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/RecursiveWorkflow.kt b/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/RecursiveWorkflow.kt new file mode 100644 index 0000000000..adfa18b98b --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/RecursiveWorkflow.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.nestedrenderings + +import com.squareup.sample.nestedrenderings.RecursiveWorkflow.LegacyRendering +import com.squareup.sample.nestedrenderings.RecursiveWorkflow.Rendering +import com.squareup.sample.nestedrenderings.RecursiveWorkflow.State +import com.squareup.workflow.RenderContext +import com.squareup.workflow.Snapshot +import com.squareup.workflow.StatefulWorkflow +import com.squareup.workflow.action +import com.squareup.workflow.renderChild + +/** + * A simple workflow that produces [Rendering]s of zero or more children. + * The rendering provides event handlers for adding children and resetting child count to zero. + * + * Every other (odd) rendering in the [Rendering.children] will be wrapped with a [LegacyRendering] + * to force it to go through the legacy view layer. This way this sample both demonstrates pass- + * through Composable renderings as well as adapting in both directions. + */ +object RecursiveWorkflow : StatefulWorkflow() { + + data class State(val children: Int = 0) + + /** + * A rendering from a [RecursiveWorkflow]. + * + * @param children A list of renderings to display as children of this rendering. + * @param onAddChildClicked Adds a child to [children]. + * @param onResetClicked Resets [children] to an empty list. + */ + data class Rendering( + val children: List, + val onAddChildClicked: () -> Unit, + val onResetClicked: () -> Unit + ) + + /** + * Wrapper around a [Rendering] that will be implemented using a legacy view. + */ + data class LegacyRendering(val rendering: Any) + + override fun initialState( + props: Unit, + snapshot: Snapshot? + ): State = State() + + override fun render( + props: Unit, + state: State, + context: RenderContext + ): Rendering { + return Rendering( + children = List(state.children) { i -> + val child = context.renderChild(RecursiveWorkflow, key = i.toString()) + if (i % 2 == 0) child else LegacyRendering(child) + }, + onAddChildClicked = { context.actionSink.send(addChild()) }, + onResetClicked = { context.actionSink.send(reset()) } + ) + } + + override fun snapshotState(state: State): Snapshot = Snapshot.EMPTY + + private fun addChild() = action { + nextState = nextState.copy(children = nextState.children + 1) + } + + private fun reset() = action { + nextState = State() + } +} diff --git a/compose/samples/src/main/java/com/squareup/sample/textinput/App.kt b/compose/samples/src/main/java/com/squareup/sample/textinput/App.kt new file mode 100644 index 0000000000..5a0b21cbcd --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/textinput/App.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.textinput + +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.ui.tooling.preview.Preview +import com.squareup.workflow.diagnostic.SimpleLoggingDiagnosticListener +import com.squareup.workflow.ui.ViewEnvironment +import com.squareup.workflow.ui.ViewRegistry +import com.squareup.workflow.ui.compose.WorkflowContainer + +private val viewRegistry = ViewRegistry(TextInputViewFactory) +private val viewEnvironment = ViewEnvironment(viewRegistry) + +@Composable fun TextInputApp() { + MaterialTheme { + WorkflowContainer( + TextInputWorkflow, viewEnvironment, + diagnosticListener = SimpleLoggingDiagnosticListener() + ) + } +} + +@Preview(showBackground = true) +@Composable internal fun TextInputAppPreview() { + TextInputApp() +} diff --git a/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputActivity.kt b/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputActivity.kt new file mode 100644 index 0000000000..34033c1155 --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputActivity.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.textinput + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.platform.setContent + +class TextInputActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + TextInputApp() + } + } +} diff --git a/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputViewFactory.kt b/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputViewFactory.kt new file mode 100644 index 0000000000..e1c10a1da7 --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputViewFactory.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.textinput + +import androidx.compose.animation.animateContentSize +import androidx.compose.material.Text +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.Button +import androidx.compose.material.OutlinedTextField +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.ExperimentalFocus +import androidx.compose.ui.unit.dp +import androidx.ui.tooling.preview.Preview +import com.squareup.sample.textinput.TextInputWorkflow.Rendering +import com.squareup.workflow.ui.compose.composedViewFactory +import com.squareup.workflow.ui.compose.tooling.preview + +@OptIn(ExperimentalFocus::class) +val TextInputViewFactory = composedViewFactory { rendering, _ -> + Column( + modifier = Modifier + .fillMaxSize() + .wrapContentSize() + .animateContentSize(clip = false), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = rendering.text) + OutlinedTextField( + label = {}, + placeholder = { Text("Enter some text") }, + value = rendering.text, + onValueChange = rendering.onTextChanged + ) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = rendering.onSwapText) { + Text("Swap") + } + } +} + +@Preview(showBackground = true) +@Composable private fun TextInputViewFactoryPreview() { + TextInputViewFactory.preview(Rendering( + text = "Hello world", + onTextChanged = {}, + onSwapText = {} + )) +} diff --git a/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputWorkflow.kt b/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputWorkflow.kt new file mode 100644 index 0000000000..0468dfb360 --- /dev/null +++ b/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputWorkflow.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.sample.textinput + +import com.squareup.sample.textinput.TextInputWorkflow.Rendering +import com.squareup.sample.textinput.TextInputWorkflow.State +import com.squareup.workflow.RenderContext +import com.squareup.workflow.Snapshot +import com.squareup.workflow.StatefulWorkflow +import com.squareup.workflow.action + +object TextInputWorkflow : StatefulWorkflow() { + + data class State( + val textA: String = "", + val textB: String = "", + val showingTextA: Boolean = true + ) + + data class Rendering( + val text: String, + val onTextChanged: (String) -> Unit, + val onSwapText: () -> Unit + ) + + private val swapText = action { + nextState = nextState.copy(showingTextA = !nextState.showingTextA) + } + + private fun changeText(text: String) = action { + nextState = if (nextState.showingTextA) { + nextState.copy(textA = text) + } else { + nextState.copy(textB = text) + } + } + + override fun initialState( + props: Unit, + snapshot: Snapshot? + ): State = State() + + override fun render( + props: Unit, + state: State, + context: RenderContext + ): Rendering = Rendering( + text = if (state.showingTextA) state.textA else state.textB, + onTextChanged = { context.actionSink.send(changeText(it)) }, + onSwapText = { context.actionSink.send(swapText) } + ) + + override fun snapshotState(state: State): Snapshot = Snapshot.EMPTY +} diff --git a/compose/samples/src/main/res/layout/legacy_view.xml b/compose/samples/src/main/res/layout/legacy_view.xml new file mode 100644 index 0000000000..e7afd62130 --- /dev/null +++ b/compose/samples/src/main/res/layout/legacy_view.xml @@ -0,0 +1,36 @@ + + + + + + + + diff --git a/compose/samples/src/main/res/values/dimens.xml b/compose/samples/src/main/res/values/dimens.xml new file mode 100644 index 0000000000..a5357738c4 --- /dev/null +++ b/compose/samples/src/main/res/values/dimens.xml @@ -0,0 +1,18 @@ + + + 16dp + diff --git a/compose/samples/src/main/res/values/strings.xml b/compose/samples/src/main/res/values/strings.xml new file mode 100644 index 0000000000..187570afd0 --- /dev/null +++ b/compose/samples/src/main/res/values/strings.xml @@ -0,0 +1,18 @@ + + + Workflow Compose Samples + diff --git a/compose/samples/src/main/res/values/styles.xml b/compose/samples/src/main/res/values/styles.xml new file mode 100644 index 0000000000..1c37bd6d5b --- /dev/null +++ b/compose/samples/src/main/res/values/styles.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/compose/settings.gradle.kts b/compose/settings.gradle.kts new file mode 100644 index 0000000000..829e1a9302 --- /dev/null +++ b/compose/settings.gradle.kts @@ -0,0 +1,22 @@ +/* + * Copyright 2017 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +rootProject.name = "workflow-compose" + +include( + ":compose-tooling", + ":core-compose", + ":samples" +) diff --git a/lint_docs.sh b/lint_docs.sh index 5ce9d469cc..c4025e26e3 100755 --- a/lint_docs.sh +++ b/lint_docs.sh @@ -17,6 +17,7 @@ find . \ -not -name 'CHANGELOG.md' \ -not -path './.github/*' \ -not -path $TUTORIALS_DIR/'*' \ + -not -path './compose/*' \ | xargs mdl --style $STYLE --ignore-front-matter \ find $TUTORIALS_DIR \