├── .github └── images │ └── sample_screenshot.png ├── .gitignore ├── .idea ├── .gitignore ├── compiler.xml └── vcs.xml ├── LICENSE ├── README.md ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── library ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro ├── publish.gradle └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── com │ └── github │ └── skgmn │ └── composetooltip │ ├── AnchorEdge.kt │ ├── EdgePosition.kt │ ├── TooltipConstraintLayout.kt │ ├── TooltipPopup.kt │ └── TooltipStyle.kt ├── sample ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── github │ │ └── skgmn │ │ └── composetooltip │ │ └── sample │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── github │ │ │ └── skgmn │ │ │ └── composetooltip │ │ │ └── sample │ │ │ ├── ExampleConstraintLayout.kt │ │ │ ├── ExamplePopup.kt │ │ │ ├── MainActivity.kt │ │ │ ├── MainScreen.kt │ │ │ └── ui │ │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── Shape.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable-xxxhdpi │ │ └── dummy.png │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values-night │ │ └── themes.xml │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── com │ └── github │ └── skgmn │ └── composetooltip │ └── sample │ └── ExampleUnitTest.kt └── settings.gradle /.github/images/sample_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skgmn/ComposeTooltip/7c4783308f55d3376039952e3dea5fdb6a5185c0/.github/images/sample_screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/ 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 skgmn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Sample app screenshot](https://github.com/skgmn/ComposeTooltip/blob/master/.github/images/sample_screenshot.png) 2 | 3 | # Setup 4 | 5 | ```gradle 6 | implementation "com.github.skgmn:composetooltip:0.2.0" 7 | ``` 8 | 9 | # Tooltip method 10 | 11 | `Tooltip` can be either used inside ConstraintLayout or displayed as a popup. 12 | 13 | # ConstraintLayout 14 | 15 | ## Basic example 16 | 17 | ```kotlin 18 | ConstraintLayout { 19 | val someImage = createRef() 20 | Image( 21 | painter = painterResource(R.drawable.some_image), 22 | contentDescription = "Some image" 23 | modifier = Modifier.constrainAs(someImage) { 24 | // Place it where you want 25 | } 26 | ) 27 | Tooltip( 28 | anchor = someImage, 29 | anchorEdge = AnchorEdge.Top, 30 | ) { 31 | Text("This is my image!") 32 | } 33 | } 34 | ``` 35 | 36 | ## Method signatures 37 | 38 | ```kotlin 39 | fun ConstraintLayoutScope.Tooltip( 40 | anchor: ConstrainedLayoutReference, 41 | anchorEdge: AnchorEdge, 42 | modifier: Modifier = Modifier, 43 | tooltipStyle: TooltipStyle = rememberTooltipStyle(), 44 | tipPosition: EdgePosition = remember { EdgePosition() }, 45 | anchorPosition: EdgePosition = remember { EdgePosition() }, 46 | margin: Dp = 8.dp, 47 | content: @Composable RowScope.() -> Unit 48 | ) 49 | ``` 50 | 51 | * `anchor` - `ConstrainedLayoutReference` to locate this tooltip nearby 52 | * `anchorEdge` - Can be either of `AnchorEdge.Start`, `AnchorEdge.Top`, `AnchorEdge.End`, or `AnchorEdge.Bottom` 53 | * `tooltipStyle` - Style for tooltip. Can be created by `rememberTooltipStyle` 54 | * `tipPosition` - Tip position relative to balloon 55 | * `anchorPosition` - Position on the `anchor`'s edge where the tip points out. 56 | * `margin` - Margin between tip and `anchor` 57 | * `content` - Content inside balloon. Typically `Text`. 58 | 59 | `Tooltip` also supports enter/exit transition using `AnimatedVisibility`. Because `AnimatedVisibility` is experimental, this method is also experimental. 60 | 61 | ```kotlin 62 | fun ConstraintLayoutScope.Tooltip( 63 | anchor: ConstrainedLayoutReference, 64 | anchorEdge: AnchorEdge, 65 | enterTransition: EnterTransition, 66 | exitTransition: ExitTransition, 67 | modifier: Modifier = Modifier, 68 | visible: Boolean = true, 69 | tooltipStyle: TooltipStyle = rememberTooltipStyle(), 70 | tipPosition: EdgePosition = remember { EdgePosition() }, 71 | anchorPosition: EdgePosition = remember { EdgePosition() }, 72 | margin: Dp = 8.dp, 73 | content: @Composable RowScope.() -> Unit 74 | ) 75 | ``` 76 | 77 | * `enterTransition` - `EnterTransition` to be applied when the `visible` becomes true. Types of `EnterTransition` are listed [here](https://developer.android.com/jetpack/compose/animation#entertransition). 78 | * `exitTransition` - `ExitTransition` to be applied when the `visible` becomes false. Types of `ExitTransition` are listed [here](https://developer.android.com/jetpack/compose/animation#exittransition). 79 | 80 | # Popup 81 | 82 | ```kotlin 83 | Box { 84 | // Some other composables here 85 | 86 | // This Box is a conatiner for anchoring 87 | Box { 88 | Image( 89 | painter = painterResource(R.drawable.some_image), 90 | contentDescription = "Some image" 91 | ) 92 | Tooltip( 93 | anchorEdge = AnchorEdge.Top, 94 | ) { 95 | Text("This is my image!") 96 | } 97 | } 98 | } 99 | ``` 100 | 101 | When `Tooltip` is being displayed as a popup, an anchor and `Tooltip` should be put altogether inside one composable. 102 | 103 | ## Method signatures 104 | 105 | ```kotlin 106 | fun Tooltip( 107 | anchorEdge: AnchorEdge, 108 | modifier: Modifier = Modifier, 109 | tooltipStyle: TooltipStyle = rememberTooltipStyle(), 110 | tipPosition: EdgePosition = remember { EdgePosition() }, 111 | anchorPosition: EdgePosition = remember { EdgePosition() }, 112 | margin: Dp = 8.dp, 113 | onDismissRequest: (() -> Unit)? = null, 114 | properties: PopupProperties = remember { PopupProperties() }, 115 | content: @Composable RowScope.() -> Unit 116 | ) 117 | ``` 118 | 119 | * `onDismissRequest` - Executes when the user clicks outside of the tooltip. 120 | * `properties` - `PopupProperties` for further customization of this tooltip's behavior. 121 | 122 | Other parameteres are same as for ConstraintLayout. 123 | 124 | There is also an another version of supporting transition. 125 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | buildscript { 3 | ext { 4 | compose_version = '1.0.2' 5 | sdk_version = 30 6 | } 7 | repositories { 8 | google() 9 | mavenCentral() 10 | } 11 | dependencies { 12 | classpath "com.android.tools.build:gradle:7.0.2" 13 | classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.21' 14 | 15 | // NOTE: Do not place your application dependencies here; they belong 16 | // in the individual module build.gradle files 17 | } 18 | } 19 | 20 | task clean(type: Delete) { 21 | delete rootProject.buildDir 22 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skgmn/ComposeTooltip/7c4783308f55d3376039952e3dea5fdb6a5185c0/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Thu Sep 09 09:44:21 KST 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /library/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /library/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'kotlin-android' 4 | } 5 | 6 | android { 7 | compileSdk sdk_version 8 | 9 | defaultConfig { 10 | minSdk 21 11 | targetSdk sdk_version 12 | 13 | consumerProguardFiles "consumer-rules.pro" 14 | } 15 | 16 | buildTypes { 17 | release { 18 | minifyEnabled false 19 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 20 | } 21 | } 22 | compileOptions { 23 | sourceCompatibility JavaVersion.VERSION_1_8 24 | targetCompatibility JavaVersion.VERSION_1_8 25 | } 26 | kotlinOptions { 27 | jvmTarget = '1.8' 28 | useIR = true 29 | } 30 | buildFeatures { 31 | compose true 32 | } 33 | composeOptions { 34 | kotlinCompilerExtensionVersion compose_version 35 | } 36 | packagingOptions { 37 | resources { 38 | excludes += '/META-INF/{AL2.0,LGPL2.1}' 39 | } 40 | } 41 | } 42 | 43 | dependencies { 44 | implementation "androidx.compose.ui:ui:$compose_version" 45 | implementation "androidx.compose.material:material:$compose_version" 46 | implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" 47 | implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-beta02" 48 | implementation 'com.google.android.material:material:1.4.0' 49 | } 50 | 51 | apply from: "publish.gradle" -------------------------------------------------------------------------------- /library/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skgmn/ComposeTooltip/7c4783308f55d3376039952e3dea5fdb6a5185c0/library/consumer-rules.pro -------------------------------------------------------------------------------- /library/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /library/publish.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: "maven-publish" 2 | apply plugin: "signing" 3 | 4 | task sourceJar(type: Jar) { 5 | from android.sourceSets.main.java.srcDirs 6 | classifier "sources" 7 | } 8 | 9 | afterEvaluate { 10 | publishing { 11 | repositories { 12 | maven { 13 | url "https://oss.sonatype.org/service/local/staging/deploy/maven2" 14 | credentials { 15 | username findProperty("OSSRH_ID") ?: "" 16 | password findProperty("OSSRH_PASSWORD") ?: "" 17 | } 18 | } 19 | } 20 | publications { 21 | release(MavenPublication) { 22 | from components.release 23 | 24 | groupId = 'com.github.skgmn' 25 | artifactId = 'composetooltip' 26 | version = "0.2.0" 27 | 28 | artifact(sourceJar) 29 | 30 | pom { 31 | name = "ComposeTooltip" 32 | description = "Tooltip library for Jetpack Compose" 33 | url = "https://github.com/skgmn/ComposeTooltip" 34 | licenses { 35 | license { 36 | name = "MIT License" 37 | url = "http://opensource.org/licenses/MIT" 38 | } 39 | } 40 | developers { 41 | developer { 42 | id = "skgmn" 43 | name = "skgmn" 44 | } 45 | } 46 | scm { 47 | connection = "scm:git@github.com:skgmn/ComposeTooltip.git" 48 | developerConnection = "scm:git@github.com:skgmn/ComposeTooltip.git" 49 | url = "https://github.com/skgmn/ComposeTooltip" 50 | } 51 | } 52 | } 53 | } 54 | } 55 | signing { 56 | sign publishing.publications.release 57 | } 58 | } -------------------------------------------------------------------------------- /library/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /library/src/main/java/com/github/skgmn/composetooltip/AnchorEdge.kt: -------------------------------------------------------------------------------- 1 | package com.github.skgmn.composetooltip 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.geometry.Size 7 | import androidx.compose.ui.graphics.Path 8 | import androidx.compose.ui.unit.* 9 | import androidx.constraintlayout.compose.ConstrainScope 10 | import androidx.constraintlayout.compose.ConstrainedLayoutReference 11 | import androidx.constraintlayout.compose.ConstraintLayout 12 | import kotlin.math.roundToInt 13 | 14 | abstract class AnchorEdge { 15 | @Composable 16 | internal abstract fun TooltipContainer( 17 | modifier: Modifier, 18 | cornerRadius: Dp, 19 | tipPosition: EdgePosition, 20 | tip: @Composable () -> Unit, 21 | content: @Composable () -> Unit 22 | ) 23 | 24 | internal open fun calculatePopupPosition( 25 | density: Density, 26 | tooltipStyle: TooltipStyle, 27 | tipPosition: EdgePosition, 28 | anchorPosition: EdgePosition, 29 | margin: Dp, 30 | anchorBounds: IntRect, 31 | layoutDirection: LayoutDirection, 32 | popupContentSize: IntSize 33 | ): IntOffset = IntOffset(0, 0) 34 | 35 | internal abstract fun ConstrainScope.outside(anchor: ConstrainedLayoutReference, margin: Dp) 36 | internal abstract fun ConstrainScope.align(anchor: ConstrainedLayoutReference, bias: Float) 37 | internal abstract fun ConstrainScope.nextTo(anchor: ConstrainedLayoutReference, margin: Dp) 38 | internal abstract fun ConstrainScope.beforeTo(anchor: ConstrainedLayoutReference, margin: Dp) 39 | 40 | internal abstract fun selectWidth(width: Dp, height: Dp): Dp 41 | internal abstract fun selectHeight(width: Dp, height: Dp): Dp 42 | internal abstract fun Modifier.minSize(tooltipStyle: TooltipStyle): Modifier 43 | internal abstract fun Path.drawTip(size: Size, layoutDirection: LayoutDirection) 44 | 45 | abstract class VerticalAnchorEdge : AnchorEdge() { 46 | override fun ConstrainScope.align(anchor: ConstrainedLayoutReference, bias: Float) { 47 | linkTo(anchor.top, anchor.bottom, bias = bias) 48 | } 49 | 50 | override fun ConstrainScope.nextTo(anchor: ConstrainedLayoutReference, margin: Dp) { 51 | top.linkTo(anchor.bottom, margin) 52 | } 53 | 54 | override fun ConstrainScope.beforeTo(anchor: ConstrainedLayoutReference, margin: Dp) { 55 | bottom.linkTo(anchor.top, margin) 56 | } 57 | 58 | override fun selectWidth(width: Dp, height: Dp): Dp { 59 | return min(width, height) 60 | } 61 | 62 | override fun selectHeight(width: Dp, height: Dp): Dp { 63 | return max(width, height) 64 | } 65 | 66 | override fun Modifier.minSize(tooltipStyle: TooltipStyle): Modifier = with(tooltipStyle) { 67 | return heightIn(min = cornerRadius * 2 + max(tipWidth, tipHeight)) 68 | } 69 | 70 | protected fun calculatePopupPositionY( 71 | density: Density, 72 | anchorBounds: IntRect, 73 | anchorPosition: EdgePosition, 74 | tooltipStyle: TooltipStyle, 75 | tipPosition: EdgePosition, 76 | popupContentSize: IntSize 77 | ): Float = with(density) { 78 | val contactPointY = anchorBounds.top + 79 | anchorBounds.height * anchorPosition.percent + 80 | anchorPosition.offset.toPx() 81 | val tangentHeight = (tooltipStyle.cornerRadius * 2 + 82 | tipPosition.offset.absoluteValue * 2 + 83 | max(tooltipStyle.tipWidth, tooltipStyle.tipHeight)).toPx() 84 | val tangentY = contactPointY - tangentHeight / 2 85 | val tipMarginY = (popupContentSize.height - tangentHeight) * tipPosition.percent 86 | val y = tangentY - tipMarginY 87 | return y 88 | } 89 | } 90 | 91 | abstract class HorizontalAnchorEdge : AnchorEdge() { 92 | override fun ConstrainScope.align(anchor: ConstrainedLayoutReference, bias: Float) { 93 | linkTo(anchor.start, anchor.end, bias = bias) 94 | } 95 | 96 | override fun ConstrainScope.nextTo(anchor: ConstrainedLayoutReference, margin: Dp) { 97 | start.linkTo(anchor.end, margin) 98 | } 99 | 100 | override fun ConstrainScope.beforeTo(anchor: ConstrainedLayoutReference, margin: Dp) { 101 | end.linkTo(anchor.start, margin) 102 | } 103 | 104 | override fun selectWidth(width: Dp, height: Dp): Dp { 105 | return max(width, height) 106 | } 107 | 108 | override fun selectHeight(width: Dp, height: Dp): Dp { 109 | return min(width, height) 110 | } 111 | 112 | override fun Modifier.minSize(tooltipStyle: TooltipStyle): Modifier = with(tooltipStyle) { 113 | return widthIn(min = cornerRadius * 2 + max(tipWidth, tipHeight)) 114 | } 115 | 116 | protected fun calculatePopupPositionX( 117 | density: Density, 118 | layoutDirection: LayoutDirection, 119 | anchorBounds: IntRect, 120 | anchorPosition: EdgePosition, 121 | tooltipStyle: TooltipStyle, 122 | tipPosition: EdgePosition, 123 | popupContentSize: IntSize 124 | ): Float = with(density) { 125 | val contactPointX = if (layoutDirection == LayoutDirection.Ltr) { 126 | anchorBounds.left + 127 | anchorBounds.width * anchorPosition.percent + 128 | anchorPosition.offset.toPx() 129 | } else { 130 | anchorBounds.right - 131 | anchorBounds.width * anchorPosition.percent - 132 | anchorPosition.offset.toPx() 133 | } 134 | val tangentWidth = (tooltipStyle.cornerRadius * 2 + 135 | tipPosition.offset.absoluteValue * 2 + 136 | max(tooltipStyle.tipWidth, tooltipStyle.tipHeight)).toPx() 137 | val tangentLeft = contactPointX - tangentWidth / 2 138 | val tipMarginLeft = (popupContentSize.width - tangentWidth) * 139 | if (layoutDirection == LayoutDirection.Ltr) { 140 | tipPosition.percent 141 | } else { 142 | 1f - tipPosition.percent 143 | } 144 | val x = tangentLeft - tipMarginLeft 145 | return x 146 | } 147 | } 148 | 149 | object Start : VerticalAnchorEdge() { 150 | override fun ConstrainScope.outside(anchor: ConstrainedLayoutReference, margin: Dp) { 151 | end.linkTo(anchor.start, margin) 152 | } 153 | 154 | override fun Path.drawTip(size: Size, layoutDirection: LayoutDirection) { 155 | when (layoutDirection) { 156 | LayoutDirection.Ltr -> { 157 | moveTo(0f, 0f) 158 | lineTo(size.width, size.height / 2f) 159 | lineTo(0f, size.height) 160 | lineTo(0f, 0f) 161 | } 162 | LayoutDirection.Rtl -> { 163 | moveTo(size.width, 0f) 164 | lineTo(0f, size.height / 2f) 165 | lineTo(size.width, size.height) 166 | lineTo(size.width, 0f) 167 | } 168 | } 169 | } 170 | 171 | @Composable 172 | override fun TooltipContainer( 173 | modifier: Modifier, 174 | cornerRadius: Dp, 175 | tipPosition: EdgePosition, 176 | tip: @Composable () -> Unit, 177 | content: @Composable () -> Unit 178 | ) { 179 | val tipPositionOffset = tipPosition.offset 180 | ConstraintLayout(modifier = modifier) { 181 | val (contentContainer, tipContainer) = createRefs() 182 | Box( 183 | modifier = Modifier 184 | .constrainAs(contentContainer) { 185 | start.linkTo(parent.start) 186 | top.linkTo(parent.top) 187 | bottom.linkTo(parent.bottom) 188 | } 189 | .padding( 190 | top = if (tipPositionOffset < 0.dp) tipPositionOffset * -2 else 0.dp, 191 | bottom = if (tipPositionOffset > 0.dp) tipPositionOffset * 2 else 0.dp 192 | ) 193 | ) { 194 | content() 195 | } 196 | val tipPadding = cornerRadius + tipPositionOffset.absoluteValue 197 | Box( 198 | modifier = Modifier 199 | .constrainAs(tipContainer) { 200 | linkTo( 201 | contentContainer.top, 202 | contentContainer.bottom, 203 | bias = tipPosition.percent 204 | ) 205 | start.linkTo(contentContainer.end) 206 | } 207 | .padding(top = tipPadding, bottom = tipPadding) 208 | ) { 209 | tip() 210 | } 211 | } 212 | } 213 | 214 | override fun calculatePopupPosition( 215 | density: Density, 216 | tooltipStyle: TooltipStyle, 217 | tipPosition: EdgePosition, 218 | anchorPosition: EdgePosition, 219 | margin: Dp, 220 | anchorBounds: IntRect, 221 | layoutDirection: LayoutDirection, 222 | popupContentSize: IntSize 223 | ): IntOffset = with(density) { 224 | val y = calculatePopupPositionY( 225 | density, 226 | anchorBounds, 227 | anchorPosition, 228 | tooltipStyle, 229 | tipPosition, 230 | popupContentSize 231 | ) 232 | val x = if (layoutDirection == LayoutDirection.Ltr) { 233 | anchorBounds.left - margin.toPx() - popupContentSize.width 234 | } else { 235 | anchorBounds.right + margin.toPx() 236 | } 237 | return IntOffset(x.roundToInt(), y.roundToInt()) 238 | } 239 | } 240 | 241 | object Top : HorizontalAnchorEdge() { 242 | override fun ConstrainScope.outside(anchor: ConstrainedLayoutReference, margin: Dp) { 243 | bottom.linkTo(anchor.top, margin) 244 | } 245 | 246 | override fun Path.drawTip(size: Size, layoutDirection: LayoutDirection) { 247 | moveTo(0f, 0f) 248 | lineTo(size.width, 0f) 249 | lineTo(size.width / 2f, size.height) 250 | lineTo(0f, 0f) 251 | } 252 | 253 | @Composable 254 | override fun TooltipContainer( 255 | modifier: Modifier, 256 | cornerRadius: Dp, 257 | tipPosition: EdgePosition, 258 | tip: @Composable () -> Unit, 259 | content: @Composable () -> Unit 260 | ) { 261 | val tipPositionOffset = tipPosition.offset 262 | ConstraintLayout(modifier = modifier) { 263 | val (contentContainer, tipContainer) = createRefs() 264 | Box( 265 | modifier = Modifier 266 | .constrainAs(contentContainer) { 267 | start.linkTo(parent.start) 268 | top.linkTo(parent.top) 269 | end.linkTo(parent.end) 270 | } 271 | .padding( 272 | start = if (tipPositionOffset < 0.dp) tipPositionOffset * -2 else 0.dp, 273 | end = if (tipPositionOffset > 0.dp) tipPositionOffset * 2 else 0.dp 274 | ) 275 | ) { 276 | content() 277 | } 278 | val tipPadding = cornerRadius + tipPositionOffset.absoluteValue 279 | Box( 280 | modifier = Modifier 281 | .constrainAs(tipContainer) { 282 | linkTo( 283 | contentContainer.start, 284 | contentContainer.end, 285 | bias = tipPosition.percent 286 | ) 287 | top.linkTo(contentContainer.bottom) 288 | } 289 | .padding(start = tipPadding, end = tipPadding) 290 | ) { 291 | tip() 292 | } 293 | } 294 | } 295 | 296 | override fun calculatePopupPosition( 297 | density: Density, 298 | tooltipStyle: TooltipStyle, 299 | tipPosition: EdgePosition, 300 | anchorPosition: EdgePosition, 301 | margin: Dp, 302 | anchorBounds: IntRect, 303 | layoutDirection: LayoutDirection, 304 | popupContentSize: IntSize 305 | ): IntOffset = with(density) { 306 | val x = calculatePopupPositionX( 307 | density, 308 | layoutDirection, 309 | anchorBounds, 310 | anchorPosition, 311 | tooltipStyle, 312 | tipPosition, 313 | popupContentSize 314 | ) 315 | val y = anchorBounds.top - margin.toPx() - popupContentSize.height 316 | return IntOffset(x.roundToInt(), y.roundToInt()) 317 | } 318 | } 319 | 320 | object End : VerticalAnchorEdge() { 321 | override fun ConstrainScope.outside(anchor: ConstrainedLayoutReference, margin: Dp) { 322 | start.linkTo(anchor.end, margin) 323 | } 324 | 325 | override fun Path.drawTip(size: Size, layoutDirection: LayoutDirection) { 326 | when (layoutDirection) { 327 | LayoutDirection.Ltr -> { 328 | moveTo(size.width, 0f) 329 | lineTo(0f, size.height / 2f) 330 | lineTo(size.width, size.height) 331 | lineTo(size.width, 0f) 332 | } 333 | LayoutDirection.Rtl -> { 334 | moveTo(0f, 0f) 335 | lineTo(size.width, size.height / 2f) 336 | lineTo(0f, size.height) 337 | lineTo(0f, 0f) 338 | } 339 | } 340 | } 341 | 342 | @Composable 343 | override fun TooltipContainer( 344 | modifier: Modifier, 345 | cornerRadius: Dp, 346 | tipPosition: EdgePosition, 347 | tip: @Composable () -> Unit, 348 | content: @Composable () -> Unit 349 | ) { 350 | val tipPositionOffset = tipPosition.offset 351 | ConstraintLayout(modifier = modifier) { 352 | val (contentContainer, tipContainer) = createRefs() 353 | Box( 354 | modifier = Modifier 355 | .constrainAs(contentContainer) { 356 | top.linkTo(parent.top) 357 | end.linkTo(parent.end) 358 | bottom.linkTo(parent.bottom) 359 | } 360 | .padding( 361 | top = if (tipPositionOffset < 0.dp) tipPositionOffset * -2 else 0.dp, 362 | bottom = if (tipPositionOffset > 0.dp) tipPositionOffset * 2 else 0.dp 363 | ) 364 | ) { 365 | content() 366 | } 367 | val tipPadding = cornerRadius + tipPositionOffset.absoluteValue 368 | Box( 369 | modifier = Modifier 370 | .constrainAs(tipContainer) { 371 | linkTo( 372 | contentContainer.top, 373 | contentContainer.bottom, 374 | bias = tipPosition.percent 375 | ) 376 | end.linkTo(contentContainer.start) 377 | } 378 | .padding(top = tipPadding, bottom = tipPadding) 379 | ) { 380 | tip() 381 | } 382 | } 383 | } 384 | 385 | override fun calculatePopupPosition( 386 | density: Density, 387 | tooltipStyle: TooltipStyle, 388 | tipPosition: EdgePosition, 389 | anchorPosition: EdgePosition, 390 | margin: Dp, 391 | anchorBounds: IntRect, 392 | layoutDirection: LayoutDirection, 393 | popupContentSize: IntSize 394 | ): IntOffset = with(density) { 395 | val y = calculatePopupPositionY( 396 | density, 397 | anchorBounds, 398 | anchorPosition, 399 | tooltipStyle, 400 | tipPosition, 401 | popupContentSize 402 | ) 403 | val x = if (layoutDirection == LayoutDirection.Ltr) { 404 | anchorBounds.right + margin.toPx() 405 | } else { 406 | anchorBounds.left - margin.toPx() - popupContentSize.width 407 | } 408 | return IntOffset(x.roundToInt(), y.roundToInt()) 409 | } 410 | } 411 | 412 | object Bottom : HorizontalAnchorEdge() { 413 | override fun ConstrainScope.outside(anchor: ConstrainedLayoutReference, margin: Dp) { 414 | top.linkTo(anchor.bottom, margin) 415 | } 416 | 417 | override fun Path.drawTip(size: Size, layoutDirection: LayoutDirection) { 418 | moveTo(0f, size.height) 419 | lineTo(size.width / 2f, 0f) 420 | lineTo(size.width, size.height) 421 | lineTo(0f, size.height) 422 | } 423 | 424 | @Composable 425 | override fun TooltipContainer( 426 | modifier: Modifier, 427 | cornerRadius: Dp, 428 | tipPosition: EdgePosition, 429 | tip: @Composable () -> Unit, 430 | content: @Composable () -> Unit 431 | ) { 432 | val tipPositionOffset = tipPosition.offset 433 | ConstraintLayout(modifier = modifier) { 434 | val (contentContainer, tipContainer) = createRefs() 435 | Box( 436 | modifier = Modifier 437 | .constrainAs(contentContainer) { 438 | start.linkTo(parent.start) 439 | end.linkTo(parent.end) 440 | bottom.linkTo(parent.bottom) 441 | } 442 | .padding( 443 | start = if (tipPositionOffset < 0.dp) tipPositionOffset * -2 else 0.dp, 444 | end = if (tipPositionOffset > 0.dp) tipPositionOffset * 2 else 0.dp 445 | ) 446 | ) { 447 | content() 448 | } 449 | val tipPadding = cornerRadius + tipPositionOffset.absoluteValue 450 | Box( 451 | modifier = Modifier 452 | .constrainAs(tipContainer) { 453 | linkTo( 454 | contentContainer.start, 455 | contentContainer.end, 456 | bias = tipPosition.percent 457 | ) 458 | bottom.linkTo(contentContainer.top) 459 | } 460 | .padding(start = tipPadding, end = tipPadding) 461 | ) { 462 | tip() 463 | } 464 | } 465 | } 466 | 467 | override fun calculatePopupPosition( 468 | density: Density, 469 | tooltipStyle: TooltipStyle, 470 | tipPosition: EdgePosition, 471 | anchorPosition: EdgePosition, 472 | margin: Dp, 473 | anchorBounds: IntRect, 474 | layoutDirection: LayoutDirection, 475 | popupContentSize: IntSize 476 | ): IntOffset = with(density) { 477 | val x = calculatePopupPositionX( 478 | density, 479 | layoutDirection, 480 | anchorBounds, 481 | anchorPosition, 482 | tooltipStyle, 483 | tipPosition, 484 | popupContentSize 485 | ) 486 | val y = anchorBounds.bottom + margin.toPx() 487 | return IntOffset(x.roundToInt(), y.roundToInt()) 488 | } 489 | } 490 | } -------------------------------------------------------------------------------- /library/src/main/java/com/github/skgmn/composetooltip/EdgePosition.kt: -------------------------------------------------------------------------------- 1 | package com.github.skgmn.composetooltip 2 | 3 | import androidx.annotation.FloatRange 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import androidx.compose.ui.unit.Dp 8 | import androidx.compose.ui.unit.dp 9 | 10 | /** 11 | * Specifies a position on an edge. 12 | */ 13 | class EdgePosition( 14 | @FloatRange(from = 0.0, to = 1.0) 15 | percent: Float = 0.5f, 16 | offset: Dp = 0.dp 17 | ) { 18 | /** 19 | * When it comes to either [AnchorEdge.Top] or [AnchorEdge.Bottom], 20 | * percent 0.0 means the horizontal start position of the anchor, 21 | * and percent 1.0 means the horizontal end position of the anchor. 22 | * 23 | * If it comes to either [AnchorEdge.Start] or [AnchorEdge.End], 24 | * percent 0.0 means the top of the anchor, and percent 1.0 means the bottom of the anchor. 25 | */ 26 | @get:FloatRange(from = 0.0, to = 1.0) 27 | @setparam:FloatRange(from = 0.0, to = 1.0) 28 | var percent by mutableStateOf(percent) 29 | 30 | /** 31 | * Amount of dps from the percentage position on the edge. 32 | * 33 | * For example, if [percent] is 0.5 and [offset] is 10.dp, tip will point out the location 34 | * of 10.dp from the center of the edge. 35 | * 36 | * This allows negative value. 37 | */ 38 | var offset by mutableStateOf(offset) 39 | } -------------------------------------------------------------------------------- /library/src/main/java/com/github/skgmn/composetooltip/TooltipConstraintLayout.kt: -------------------------------------------------------------------------------- 1 | package com.github.skgmn.composetooltip 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.EnterTransition 5 | import androidx.compose.animation.ExitTransition 6 | import androidx.compose.animation.ExperimentalAnimationApi 7 | import androidx.compose.animation.core.MutableTransitionState 8 | import androidx.compose.foundation.background 9 | import androidx.compose.foundation.layout.* 10 | import androidx.compose.foundation.shape.GenericShape 11 | import androidx.compose.foundation.shape.RoundedCornerShape 12 | import androidx.compose.material.LocalContentColor 13 | import androidx.compose.material.Text 14 | import androidx.compose.material.contentColorFor 15 | import androidx.compose.runtime.* 16 | import androidx.compose.ui.Alignment 17 | import androidx.compose.ui.Modifier 18 | import androidx.compose.ui.unit.Dp 19 | import androidx.compose.ui.unit.dp 20 | import androidx.compose.ui.unit.max 21 | import androidx.constraintlayout.compose.ConstrainedLayoutReference 22 | import androidx.constraintlayout.compose.ConstraintLayoutScope 23 | 24 | /** 25 | * Show a tooltip near to [anchor]. 26 | * 27 | * @param anchor [ConstrainedLayoutReference] to locate this tooltip nearby 28 | * @param anchorEdge Can be either of [AnchorEdge.Start], [AnchorEdge.Top], [AnchorEdge.End], 29 | * or [AnchorEdge.Bottom] 30 | * @param modifier Modifier for tooltip. Do not use layout-related modifiers except size 31 | * constraints. 32 | * @param tooltipStyle Style for tooltip. Can be created by [rememberTooltipStyle] 33 | * @param tipPosition Tip position relative to balloon 34 | * @param anchorPosition Position on the [anchor]'s edge where the tip points out. 35 | * @param margin Margin between tip and [anchor] 36 | * @param content Content inside balloon. Typically [Text]. 37 | */ 38 | @Composable 39 | fun ConstraintLayoutScope.Tooltip( 40 | anchor: ConstrainedLayoutReference, 41 | anchorEdge: AnchorEdge, 42 | modifier: Modifier = Modifier, 43 | tooltipStyle: TooltipStyle = rememberTooltipStyle(), 44 | tipPosition: EdgePosition = remember { EdgePosition() }, 45 | anchorPosition: EdgePosition = remember { EdgePosition() }, 46 | margin: Dp = 8.dp, 47 | content: @Composable RowScope.() -> Unit, 48 | ) = with(anchorEdge) { 49 | val refs = remember { TooltipReferences(this@Tooltip) } 50 | 51 | AnchorHelpers( 52 | anchorEdge = anchorEdge, 53 | anchor = anchor, 54 | refs = refs, 55 | margin = margin, 56 | anchorPosition = anchorPosition, 57 | tipPosition = tipPosition, 58 | tooltipStyle = tooltipStyle 59 | ) 60 | TooltipImpl( 61 | anchorEdge = anchorEdge, 62 | modifier = modifier.constrainAs(refs.tooltipContainer) { 63 | outside(refs.tangent, 0.dp) 64 | align(refs.tangent, tipPosition.percent) 65 | }, 66 | tooltipStyle = tooltipStyle, 67 | tipPosition = tipPosition, 68 | content = content 69 | ) 70 | } 71 | 72 | /** 73 | * Show a tooltip near to [anchor] with transition. 74 | * As [AnimatedVisibility] is experimental, this function is also experimental. 75 | * 76 | * @param anchor [ConstrainedLayoutReference] to locate this tooltip nearby 77 | * @param anchorEdge Can be either of [AnchorEdge.Start], [AnchorEdge.Top], [AnchorEdge.End], 78 | * or [AnchorEdge.Bottom] 79 | * @param enterTransition [EnterTransition] to be applied when the [visible] becomes true. 80 | * Types of [EnterTransition] are listed [here](https://developer.android.com/jetpack/compose/animation#entertransition). 81 | * @param exitTransition [ExitTransition] to be applied when the [visible] becomes false. 82 | * Types of [ExitTransition] are listed [here](https://developer.android.com/jetpack/compose/animation#exittransition). 83 | * @param modifier Modifier for tooltip. Do not use layout-related modifiers except size 84 | * constraints. 85 | * @param visible Visibility of tooltip 86 | * @param tooltipStyle Style for tooltip. Can be created by [rememberTooltipStyle] 87 | * @param tipPosition Tip position relative to balloon 88 | * @param anchorPosition Position on the [anchor]'s edge where the tip points out. 89 | * @param margin Margin between tip and [anchor] 90 | * @param content Content inside balloon. Typically [Text]. 91 | */ 92 | @ExperimentalAnimationApi 93 | @Composable 94 | fun ConstraintLayoutScope.Tooltip( 95 | anchor: ConstrainedLayoutReference, 96 | anchorEdge: AnchorEdge, 97 | enterTransition: EnterTransition, 98 | exitTransition: ExitTransition, 99 | modifier: Modifier = Modifier, 100 | visible: Boolean = true, 101 | tooltipStyle: TooltipStyle = rememberTooltipStyle(), 102 | tipPosition: EdgePosition = remember { EdgePosition() }, 103 | anchorPosition: EdgePosition = remember { EdgePosition() }, 104 | margin: Dp = 8.dp, 105 | content: @Composable RowScope.() -> Unit, 106 | ) = with(anchorEdge) { 107 | // I don't know why but AnimatedVisibility without MutableTransitionState does'nt work 108 | val visibleState = remember { MutableTransitionState(visible) } 109 | visibleState.targetState = visible 110 | 111 | val refs = remember { TooltipReferences(this@Tooltip) } 112 | 113 | AnchorHelpers( 114 | anchorEdge = anchorEdge, 115 | anchor = anchor, 116 | refs = refs, 117 | margin = margin, 118 | anchorPosition = anchorPosition, 119 | tipPosition = tipPosition, 120 | tooltipStyle = tooltipStyle 121 | ) 122 | AnimatedVisibility( 123 | visibleState = visibleState, 124 | modifier = Modifier.constrainAs(refs.tooltipContainer) { 125 | outside(refs.tangent, 0.dp) 126 | align(refs.tangent, tipPosition.percent) 127 | }, 128 | enter = enterTransition, 129 | exit = exitTransition 130 | ) { 131 | TooltipImpl( 132 | modifier = modifier, 133 | anchorEdge = anchorEdge, 134 | tipPosition = tipPosition, 135 | tooltipStyle = tooltipStyle, 136 | content = content 137 | ) 138 | } 139 | } 140 | 141 | @Composable 142 | private fun ConstraintLayoutScope.AnchorHelpers( 143 | anchorEdge: AnchorEdge, 144 | anchor: ConstrainedLayoutReference, 145 | refs: TooltipReferences, 146 | margin: Dp, 147 | anchorPosition: EdgePosition, 148 | tipPosition: EdgePosition, 149 | tooltipStyle: TooltipStyle 150 | ) { 151 | ContactPoint(anchor, anchorEdge, anchorPosition, refs, margin) 152 | Tangent(anchorEdge, tooltipStyle, refs, tipPosition) 153 | } 154 | 155 | @Composable 156 | private fun ConstraintLayoutScope.ContactPoint( 157 | anchor: ConstrainedLayoutReference, 158 | anchorEdge: AnchorEdge, 159 | anchorPosition: EdgePosition, 160 | refs: TooltipReferences, 161 | margin: Dp 162 | ) = with(anchorEdge) { 163 | val positionOffset = anchorPosition.offset 164 | if (positionOffset == 0.dp) { 165 | Spacer( 166 | modifier = Modifier 167 | .size(selectWidth(1.dp, 0.dp), selectHeight(1.dp, 0.dp)) 168 | .constrainAs(refs.contactPoint) { 169 | outside(anchor, margin) 170 | align(anchor, anchorPosition.percent) 171 | } 172 | ) 173 | } else { 174 | Spacer( 175 | modifier = Modifier 176 | .size(0.dp, 0.dp) 177 | .constrainAs(refs.contactPointOrigin) { 178 | align(anchor, anchorPosition.percent) 179 | } 180 | ) 181 | Spacer( 182 | modifier = Modifier 183 | .size(0.dp, 0.dp) 184 | .constrainAs(refs.contactPoint) { 185 | outside(anchor, margin) 186 | if (positionOffset > 0.dp) { 187 | nextTo(refs.contactPointOrigin, positionOffset) 188 | } else { 189 | beforeTo(refs.contactPointOrigin, -positionOffset) 190 | } 191 | } 192 | ) 193 | } 194 | } 195 | 196 | @Composable 197 | private fun ConstraintLayoutScope.Tangent( 198 | anchorEdge: AnchorEdge, 199 | tooltipStyle: TooltipStyle, 200 | refs: TooltipReferences, 201 | tipPosition: EdgePosition 202 | ) = with(anchorEdge) { 203 | val tangentWidth = tooltipStyle.cornerRadius * 2 + 204 | tipPosition.offset.absoluteValue * 2 + 205 | max(tooltipStyle.tipWidth, tooltipStyle.tipHeight) 206 | Spacer( 207 | modifier = Modifier 208 | .size(selectWidth(tangentWidth, 0.dp), selectHeight(tangentWidth, 0.dp)) 209 | .constrainAs(refs.tangent) { 210 | outside(refs.contactPoint, 0.dp) 211 | align(refs.contactPoint, 0.5f) 212 | } 213 | ) 214 | } 215 | 216 | @Composable 217 | private fun ConstraintLayoutScope.TooltipImpl( 218 | anchorEdge: AnchorEdge, 219 | tipPosition: EdgePosition, 220 | modifier: Modifier, 221 | tooltipStyle: TooltipStyle, 222 | content: @Composable RowScope.() -> Unit 223 | ) = with(anchorEdge) { 224 | TooltipContainer( 225 | modifier = modifier, 226 | cornerRadius = tooltipStyle.cornerRadius, 227 | tipPosition = tipPosition, 228 | tip = { Tip(anchorEdge, tooltipStyle) }, 229 | content = { TooltipContentContainer(anchorEdge, tooltipStyle, content) } 230 | ) 231 | } 232 | 233 | @Composable 234 | internal fun Tip(anchorEdge: AnchorEdge, tooltipStyle: TooltipStyle) = with(anchorEdge) { 235 | Box(modifier = Modifier 236 | .size( 237 | width = anchorEdge.selectWidth( 238 | tooltipStyle.tipWidth, 239 | tooltipStyle.tipHeight 240 | ), 241 | height = anchorEdge.selectHeight( 242 | tooltipStyle.tipWidth, 243 | tooltipStyle.tipHeight 244 | ) 245 | ) 246 | .background( 247 | color = tooltipStyle.color, 248 | shape = GenericShape { size, layoutDirection -> 249 | this.drawTip(size, layoutDirection) 250 | } 251 | ) 252 | ) 253 | } 254 | 255 | @Composable 256 | internal fun TooltipContentContainer( 257 | anchorEdge: AnchorEdge, 258 | tooltipStyle: TooltipStyle, 259 | content: @Composable (RowScope.() -> Unit) 260 | ) = with(anchorEdge) { 261 | Row( 262 | modifier = Modifier.Companion 263 | .minSize(tooltipStyle) 264 | .background( 265 | color = tooltipStyle.color, 266 | shape = RoundedCornerShape(tooltipStyle.cornerRadius) 267 | ) 268 | .padding(tooltipStyle.contentPadding), 269 | horizontalArrangement = Arrangement.Center, 270 | verticalAlignment = Alignment.CenterVertically 271 | ) { 272 | CompositionLocalProvider( 273 | LocalContentColor provides contentColorFor(tooltipStyle.color) 274 | ) { 275 | content() 276 | } 277 | } 278 | } 279 | 280 | private class TooltipReferences(scope: ConstraintLayoutScope) { 281 | val contactPointOrigin = scope.createRef() 282 | val contactPoint = scope.createRef() 283 | val tangent = scope.createRef() 284 | val tooltipContainer = scope.createRef() 285 | } 286 | 287 | internal val Dp.absoluteValue get() = if (this < 0.dp) -this else this -------------------------------------------------------------------------------- /library/src/main/java/com/github/skgmn/composetooltip/TooltipPopup.kt: -------------------------------------------------------------------------------- 1 | package com.github.skgmn.composetooltip 2 | 3 | import androidx.compose.animation.AnimatedVisibility 4 | import androidx.compose.animation.EnterTransition 5 | import androidx.compose.animation.ExitTransition 6 | import androidx.compose.animation.ExperimentalAnimationApi 7 | import androidx.compose.foundation.layout.RowScope 8 | import androidx.compose.material.Text 9 | import androidx.compose.runtime.* 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.draw.alpha 12 | import androidx.compose.ui.platform.LocalDensity 13 | import androidx.compose.ui.unit.* 14 | import androidx.compose.ui.window.Popup 15 | import androidx.compose.ui.window.PopupPositionProvider 16 | import androidx.compose.ui.window.PopupProperties 17 | import kotlinx.coroutines.delay 18 | 19 | private const val TRANSITION_INITIALIZE = 0 20 | private const val TRANSITION_ENTER = 1 21 | private const val TRANSITION_EXIT = 2 22 | private const val TRANSITION_GONE = 3 23 | 24 | /** 25 | * Show a tooltip as popup near to an anchor. 26 | * Anchor can be provided by putting the anchor and Tooltip altogether in one composable. 27 | * 28 | * Example: 29 | * ```kotlin 30 | * Box { 31 | * AnchorComposable() 32 | * Tooltip() 33 | * } 34 | * ``` 35 | * 36 | * @param anchorEdge Can be either of [AnchorEdge.Start], [AnchorEdge.Top], [AnchorEdge.End], 37 | * or [AnchorEdge.Bottom] 38 | * @param modifier Modifier for tooltip. Do not use layout-related modifiers except size 39 | * constraints. 40 | * @param tooltipStyle Style for tooltip. Can be created by [rememberTooltipStyle] 41 | * @param tipPosition Tip position relative to balloon 42 | * @param anchorPosition Position on the anchor's edge where the tip points out. 43 | * @param margin Margin between tip and anchor 44 | * @param onDismissRequest Executes when the user clicks outside of the tooltip. 45 | * @param properties [PopupProperties] for further customization of this tooltip's behavior. 46 | * @param content Content inside balloon. Typically [Text]. 47 | */ 48 | @Composable 49 | fun Tooltip( 50 | anchorEdge: AnchorEdge, 51 | modifier: Modifier = Modifier, 52 | tooltipStyle: TooltipStyle = rememberTooltipStyle(), 53 | tipPosition: EdgePosition = remember { EdgePosition() }, 54 | anchorPosition: EdgePosition = remember { EdgePosition() }, 55 | margin: Dp = 8.dp, 56 | onDismissRequest: (() -> Unit)? = null, 57 | properties: PopupProperties = remember { PopupProperties() }, 58 | content: @Composable RowScope.() -> Unit, 59 | ) = with(anchorEdge) { 60 | Popup( 61 | popupPositionProvider = TooltipPopupPositionProvider( 62 | LocalDensity.current, 63 | anchorEdge, 64 | tooltipStyle, 65 | tipPosition, 66 | anchorPosition, 67 | margin 68 | ), 69 | onDismissRequest = onDismissRequest, 70 | properties = properties 71 | ) { 72 | TooltipImpl( 73 | modifier = modifier, 74 | tooltipStyle = tooltipStyle, 75 | tipPosition = tipPosition, 76 | anchorEdge = anchorEdge, 77 | content = content 78 | ) 79 | } 80 | } 81 | 82 | /** 83 | * Show a tooltip as popup near to an anchor with transition. 84 | * As [AnimatedVisibility] is experimental, this function is also experimental. 85 | * Anchor can be provided by putting the anchor and Tooltip altogether in one composable. 86 | * 87 | * Example: 88 | * ```kotlin 89 | * Box { 90 | * AnchorComposable() 91 | * Tooltip() 92 | * } 93 | * ``` 94 | * 95 | * @param anchorEdge Can be either of [AnchorEdge.Start], [AnchorEdge.Top], [AnchorEdge.End], 96 | * or [AnchorEdge.Bottom] 97 | * @param enterTransition [EnterTransition] to be applied when the [visible] becomes true. 98 | * Types of [EnterTransition] are listed [here](https://developer.android.com/jetpack/compose/animation#entertransition). 99 | * @param exitTransition [ExitTransition] to be applied when the [visible] becomes false. 100 | * Types of [ExitTransition] are listed [here](https://developer.android.com/jetpack/compose/animation#exittransition). 101 | * @param modifier Modifier for tooltip. Do not use layout-related modifiers except size 102 | * constraints. 103 | * @param tooltipStyle Style for tooltip. Can be created by [rememberTooltipStyle] 104 | * @param tipPosition Tip position relative to balloon 105 | * @param anchorPosition Position on the anchor's edge where the tip points out. 106 | * @param margin Margin between tip and anchor 107 | * @param onDismissRequest Executes when the user clicks outside of the tooltip. 108 | * @param properties [PopupProperties] for further customization of this tooltip's behavior. 109 | * @param content Content inside balloon. Typically [Text]. 110 | */ 111 | @ExperimentalAnimationApi 112 | @Composable 113 | fun Tooltip( 114 | anchorEdge: AnchorEdge, 115 | enterTransition: EnterTransition, 116 | exitTransition: ExitTransition, 117 | modifier: Modifier = Modifier, 118 | visible: Boolean = true, 119 | tooltipStyle: TooltipStyle = rememberTooltipStyle(), 120 | tipPosition: EdgePosition = remember { EdgePosition() }, 121 | anchorPosition: EdgePosition = remember { EdgePosition() }, 122 | margin: Dp = 8.dp, 123 | onDismissRequest: (() -> Unit)? = null, 124 | properties: PopupProperties = remember { PopupProperties() }, 125 | content: @Composable RowScope.() -> Unit, 126 | ) = with(anchorEdge) { 127 | var transitionState by remember { mutableStateOf(TRANSITION_GONE) } 128 | LaunchedEffect(visible) { 129 | if (visible) { 130 | when (transitionState) { 131 | TRANSITION_EXIT -> transitionState = TRANSITION_ENTER 132 | TRANSITION_GONE -> { 133 | transitionState = TRANSITION_INITIALIZE 134 | delay(1) 135 | transitionState = TRANSITION_ENTER 136 | } 137 | } 138 | } else { 139 | when (transitionState) { 140 | TRANSITION_INITIALIZE -> transitionState = TRANSITION_GONE 141 | TRANSITION_ENTER -> transitionState = TRANSITION_EXIT 142 | } 143 | } 144 | } 145 | if (transitionState != TRANSITION_GONE) { 146 | Popup( 147 | popupPositionProvider = TooltipPopupPositionProvider( 148 | LocalDensity.current, 149 | anchorEdge, 150 | tooltipStyle, 151 | tipPosition, 152 | anchorPosition, 153 | margin 154 | ), 155 | onDismissRequest = onDismissRequest, 156 | properties = properties 157 | ) { 158 | if (transitionState == TRANSITION_INITIALIZE) { 159 | TooltipImpl( 160 | tooltipStyle = tooltipStyle, 161 | tipPosition = tipPosition, 162 | anchorEdge = anchorEdge, 163 | modifier = modifier.alpha(0f), 164 | content = content, 165 | ) 166 | } 167 | AnimatedVisibility( 168 | visible = transitionState == TRANSITION_ENTER, 169 | enter = enterTransition, 170 | exit = exitTransition 171 | ) { 172 | remember { 173 | object : RememberObserver { 174 | override fun onAbandoned() { 175 | transitionState = TRANSITION_GONE 176 | } 177 | 178 | override fun onForgotten() { 179 | transitionState = TRANSITION_GONE 180 | } 181 | 182 | override fun onRemembered() { 183 | } 184 | } 185 | } 186 | TooltipImpl( 187 | modifier = modifier, 188 | tooltipStyle = tooltipStyle, 189 | tipPosition = tipPosition, 190 | anchorEdge = anchorEdge, 191 | content = content 192 | ) 193 | } 194 | } 195 | } 196 | } 197 | 198 | @Composable 199 | private fun AnchorEdge.TooltipImpl( 200 | tooltipStyle: TooltipStyle, 201 | tipPosition: EdgePosition, 202 | anchorEdge: AnchorEdge, 203 | modifier: Modifier = Modifier, 204 | content: @Composable (RowScope.() -> Unit) 205 | ) { 206 | TooltipContainer( 207 | modifier = modifier, 208 | cornerRadius = tooltipStyle.cornerRadius, 209 | tipPosition = tipPosition, 210 | tip = { Tip(anchorEdge, tooltipStyle) }, 211 | content = { 212 | TooltipContentContainer( 213 | anchorEdge = anchorEdge, 214 | tooltipStyle = tooltipStyle, 215 | content = content 216 | ) 217 | } 218 | ) 219 | } 220 | 221 | private class TooltipPopupPositionProvider( 222 | private val density: Density, 223 | private val anchorEdge: AnchorEdge, 224 | private val tooltipStyle: TooltipStyle, 225 | private val tipPosition: EdgePosition, 226 | private val anchorPosition: EdgePosition, 227 | private val margin: Dp 228 | ) : PopupPositionProvider { 229 | override fun calculatePosition( 230 | anchorBounds: IntRect, 231 | windowSize: IntSize, 232 | layoutDirection: LayoutDirection, 233 | popupContentSize: IntSize 234 | ): IntOffset = anchorEdge.calculatePopupPosition( 235 | density, 236 | tooltipStyle, 237 | tipPosition, 238 | anchorPosition, 239 | margin, 240 | anchorBounds, 241 | layoutDirection, 242 | popupContentSize 243 | ) 244 | } -------------------------------------------------------------------------------- /library/src/main/java/com/github/skgmn/composetooltip/TooltipStyle.kt: -------------------------------------------------------------------------------- 1 | package com.github.skgmn.composetooltip 2 | 3 | import androidx.compose.foundation.layout.PaddingValues 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.runtime.* 6 | import androidx.compose.ui.graphics.Color 7 | import androidx.compose.ui.unit.Dp 8 | import androidx.compose.ui.unit.dp 9 | 10 | /** 11 | * Style of tooltip 12 | */ 13 | class TooltipStyle internal constructor( 14 | color: Color, 15 | cornerRadius: Dp, 16 | tipWidth: Dp, 17 | tipHeight: Dp, 18 | contentPadding: PaddingValues 19 | ) { 20 | /** 21 | * Background color of tooltip. 22 | */ 23 | var color by mutableStateOf(color) 24 | 25 | /** 26 | * Corner radius of balloon. 27 | */ 28 | var cornerRadius by mutableStateOf(cornerRadius) 29 | 30 | /** 31 | * Width of tip. 32 | */ 33 | var tipWidth by mutableStateOf(tipWidth) 34 | 35 | /** 36 | * Height of tip. 37 | */ 38 | var tipHeight by mutableStateOf(tipHeight) 39 | 40 | /** 41 | * Padding between balloon and content. 42 | */ 43 | var contentPadding by mutableStateOf(contentPadding) 44 | } 45 | 46 | /** 47 | * Create a [TooltipStyle] and remember it. 48 | * 49 | * @param color Background color of tooltip. By default, it uses the seconday color of 50 | * [MaterialTheme]. 51 | * @param cornerRadius Corner radius of balloon. 52 | * @param tipWidth Width of tip. 53 | * @param tipHeight Height of tip. 54 | * @param contentPadding Padding between balloon and content. 55 | */ 56 | @Composable 57 | fun rememberTooltipStyle( 58 | color: Color = MaterialTheme.colors.secondary, 59 | cornerRadius: Dp = 8.dp, 60 | tipWidth: Dp = 24.dp, 61 | tipHeight: Dp = 8.dp, 62 | contentPadding: PaddingValues = PaddingValues(12.dp), 63 | ): TooltipStyle { 64 | return remember { TooltipStyle(color, cornerRadius, tipWidth, tipHeight, contentPadding) } 65 | } -------------------------------------------------------------------------------- /sample/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /sample/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | } 5 | 6 | android { 7 | compileSdk sdk_version 8 | 9 | defaultConfig { 10 | applicationId "com.github.skgmn.composetooltip.sample" 11 | minSdk 21 12 | targetSdk sdk_version 13 | versionCode 1 14 | versionName "1.0" 15 | 16 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 17 | vectorDrawables { 18 | useSupportLibrary true 19 | } 20 | } 21 | 22 | buildTypes { 23 | release { 24 | minifyEnabled false 25 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 26 | } 27 | } 28 | compileOptions { 29 | sourceCompatibility JavaVersion.VERSION_1_8 30 | targetCompatibility JavaVersion.VERSION_1_8 31 | } 32 | kotlinOptions { 33 | jvmTarget = '1.8' 34 | useIR = true 35 | freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn" 36 | } 37 | buildFeatures { 38 | compose true 39 | } 40 | composeOptions { 41 | kotlinCompilerExtensionVersion compose_version 42 | kotlinCompilerVersion '1.5.21' 43 | } 44 | packagingOptions { 45 | resources { 46 | excludes += '/META-INF/{AL2.0,LGPL2.1}' 47 | } 48 | } 49 | } 50 | 51 | dependencies { 52 | implementation project(":library") 53 | implementation 'androidx.core:core-ktx:1.6.0' 54 | implementation 'androidx.appcompat:appcompat:1.3.1' 55 | implementation 'com.google.android.material:material:1.4.0' 56 | implementation "androidx.compose.ui:ui:$compose_version" 57 | implementation "androidx.compose.material:material:$compose_version" 58 | implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" 59 | implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-beta02" 60 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' 61 | implementation 'androidx.activity:activity-compose:1.3.1' 62 | debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" 63 | } -------------------------------------------------------------------------------- /sample/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /sample/src/androidTest/java/com/github/skgmn/composetooltip/sample/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.skgmn.composetooltip.sample 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.github.skgmn.composetooltip.sample", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /sample/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 12 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /sample/src/main/java/com/github/skgmn/composetooltip/sample/ExampleConstraintLayout.kt: -------------------------------------------------------------------------------- 1 | package com.github.skgmn.composetooltip.sample 2 | 3 | import androidx.compose.animation.ExperimentalAnimationApi 4 | import androidx.compose.animation.fadeIn 5 | import androidx.compose.animation.fadeOut 6 | import androidx.compose.foundation.Image 7 | import androidx.compose.foundation.background 8 | import androidx.compose.foundation.clickable 9 | import androidx.compose.foundation.gestures.detectDragGestures 10 | import androidx.compose.foundation.interaction.MutableInteractionSource 11 | import androidx.compose.foundation.layout.* 12 | import androidx.compose.material.Slider 13 | import androidx.compose.material.Text 14 | import androidx.compose.runtime.* 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.graphics.Color 18 | import androidx.compose.ui.input.pointer.consumeAllChanges 19 | import androidx.compose.ui.input.pointer.pointerInput 20 | import androidx.compose.ui.layout.onSizeChanged 21 | import androidx.compose.ui.res.painterResource 22 | import androidx.compose.ui.unit.IntSize 23 | import androidx.compose.ui.unit.dp 24 | import androidx.constraintlayout.compose.ConstrainedLayoutReference 25 | import androidx.constraintlayout.compose.ConstraintLayout 26 | import androidx.constraintlayout.compose.ConstraintLayoutScope 27 | import com.github.skgmn.composetooltip.AnchorEdge 28 | import com.github.skgmn.composetooltip.EdgePosition 29 | import com.github.skgmn.composetooltip.Tooltip 30 | 31 | @OptIn(ExperimentalAnimationApi::class) 32 | @Composable 33 | fun ExampleConstraintLayout(contentPaddings: PaddingValues) { 34 | var screenSize by remember { mutableStateOf(IntSize.Zero) } 35 | ConstraintLayout( 36 | modifier = Modifier 37 | .fillMaxSize() 38 | .padding(contentPaddings) 39 | .onSizeChanged { screenSize = it } 40 | ) { 41 | val anchorEdgeState = remember { mutableStateOf(AnchorEdge.Top) } 42 | 43 | var imageBiasX by remember { mutableStateOf(0.5f) } 44 | var imageBiasY by remember { mutableStateOf(0.5f) } 45 | 46 | val tipPosition = remember { EdgePosition() } 47 | val anchorPosition = remember { EdgePosition() } 48 | 49 | val tooltipVisibleState = remember { mutableStateOf(true) } 50 | 51 | val anchor = createRef() 52 | val arrows = createRefs() 53 | val bottomPanel = createRef() 54 | 55 | BottomPanel(bottomPanel, tipPosition, anchorPosition) 56 | 57 | Image( 58 | painter = painterResource(R.drawable.dummy), 59 | contentDescription = "dummy", 60 | modifier = Modifier 61 | .constrainAs(anchor) { 62 | linkTo( 63 | parent.start, parent.top, parent.end, parent.bottom, 64 | horizontalBias = imageBiasX, 65 | verticalBias = imageBiasY 66 | ) 67 | } 68 | .size(64.dp) 69 | .background(Color(0xffff0000)) 70 | .pointerInput(Unit) { 71 | detectDragGestures { change, dragAmount -> 72 | change.consumeAllChanges() 73 | imageBiasX += dragAmount.x / screenSize.width 74 | imageBiasY += dragAmount.y / screenSize.height 75 | } 76 | } 77 | ) 78 | 79 | AnchorChangeButtons(anchorEdgeState, tooltipVisibleState, arrows, anchor) 80 | 81 | Tooltip( 82 | anchor = anchor, 83 | anchorEdge = anchorEdgeState.value, 84 | visible = tooltipVisibleState.value, 85 | enterTransition = fadeIn(), 86 | exitTransition = fadeOut(), 87 | tipPosition = tipPosition, 88 | anchorPosition = anchorPosition, 89 | modifier = Modifier 90 | .clickable(remember { MutableInteractionSource() }, null) { 91 | tooltipVisibleState.value = false 92 | } 93 | ) { 94 | Text( 95 | text = "Drag icon to move it.\nTouch tooltip to dismiss it.\nTouch arrows to move anchor edge.", 96 | modifier = Modifier.widthIn(max = 200.dp) 97 | ) 98 | } 99 | } 100 | } 101 | 102 | @Composable 103 | private fun ConstraintLayoutScope.BottomPanel( 104 | ref: ConstrainedLayoutReference, 105 | tipPosition: EdgePosition, 106 | anchorPosition: EdgePosition 107 | ) { 108 | val firstColumnWeight = 0.3f 109 | Column( 110 | modifier = Modifier 111 | .constrainAs(ref) { 112 | start.linkTo(parent.start) 113 | end.linkTo(parent.end) 114 | bottom.linkTo(parent.bottom) 115 | } 116 | ) { 117 | Row( 118 | modifier = Modifier 119 | .fillMaxWidth() 120 | .padding(start = 16.dp), 121 | verticalAlignment = Alignment.CenterVertically 122 | ) { 123 | Text( 124 | text = "Tip position", 125 | modifier = Modifier.weight(firstColumnWeight) 126 | ) 127 | Slider( 128 | value = tipPosition.percent, 129 | onValueChange = { tipPosition.percent = it }, 130 | modifier = Modifier.weight(1f - firstColumnWeight) 131 | ) 132 | } 133 | Row( 134 | modifier = Modifier 135 | .fillMaxWidth() 136 | .padding(start = 16.dp), 137 | verticalAlignment = Alignment.CenterVertically 138 | ) { 139 | Text( 140 | text = "Anchor position", 141 | modifier = Modifier.weight(firstColumnWeight) 142 | ) 143 | Slider( 144 | value = anchorPosition.percent, 145 | onValueChange = { anchorPosition.percent = it }, 146 | modifier = Modifier.weight(1f - firstColumnWeight) 147 | ) 148 | } 149 | } 150 | } 151 | 152 | @Composable 153 | private fun ConstraintLayoutScope.AnchorChangeButtons( 154 | anchorEdgeState: MutableState, 155 | tooltipVisibleState: MutableState, 156 | refs: ConstraintLayoutScope.ConstrainedLayoutReferences, 157 | anchor: ConstrainedLayoutReference 158 | ) { 159 | val padding = 8.dp 160 | val anchorEdge by anchorEdgeState 161 | if (!tooltipVisibleState.value || anchorEdge != AnchorEdge.Start) { 162 | Text( 163 | text = "⬅", 164 | modifier = Modifier 165 | .constrainAs(refs.component1()) { 166 | end.linkTo(anchor.start, 8.dp) 167 | centerVerticallyTo(anchor) 168 | } 169 | .clickable { 170 | anchorEdgeState.value = AnchorEdge.Start 171 | tooltipVisibleState.value = true 172 | } 173 | .padding(padding) 174 | ) 175 | } 176 | if (!tooltipVisibleState.value || anchorEdge != AnchorEdge.Top) { 177 | Text( 178 | text = "⬆", 179 | modifier = Modifier 180 | .constrainAs(refs.component2()) { 181 | bottom.linkTo(anchor.top, 8.dp) 182 | centerHorizontallyTo(anchor) 183 | } 184 | .clickable { 185 | anchorEdgeState.value = AnchorEdge.Top 186 | tooltipVisibleState.value = true 187 | } 188 | .padding(padding) 189 | ) 190 | } 191 | if (!tooltipVisibleState.value || anchorEdge != AnchorEdge.End) { 192 | Text( 193 | text = "➡", 194 | modifier = Modifier 195 | .constrainAs(refs.component3()) { 196 | start.linkTo(anchor.end, 8.dp) 197 | centerVerticallyTo(anchor) 198 | } 199 | .clickable { 200 | anchorEdgeState.value = AnchorEdge.End 201 | tooltipVisibleState.value = true 202 | } 203 | .padding(padding) 204 | ) 205 | } 206 | if (!tooltipVisibleState.value || anchorEdge != AnchorEdge.Bottom) { 207 | Text( 208 | text = "⬇", 209 | modifier = Modifier 210 | .constrainAs(refs.component4()) { 211 | top.linkTo(anchor.bottom, 8.dp) 212 | centerHorizontallyTo(anchor) 213 | } 214 | .clickable { 215 | anchorEdgeState.value = AnchorEdge.Bottom 216 | tooltipVisibleState.value = true 217 | } 218 | .padding(padding) 219 | ) 220 | } 221 | } -------------------------------------------------------------------------------- /sample/src/main/java/com/github/skgmn/composetooltip/sample/ExamplePopup.kt: -------------------------------------------------------------------------------- 1 | package com.github.skgmn.composetooltip.sample 2 | 3 | import androidx.compose.animation.* 4 | import androidx.compose.foundation.Image 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.clickable 7 | import androidx.compose.foundation.gestures.detectDragGestures 8 | import androidx.compose.foundation.interaction.MutableInteractionSource 9 | import androidx.compose.foundation.layout.* 10 | import androidx.compose.material.ScaffoldState 11 | import androidx.compose.material.Slider 12 | import androidx.compose.material.Text 13 | import androidx.compose.runtime.* 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.draw.alpha 17 | import androidx.compose.ui.graphics.Color 18 | import androidx.compose.ui.input.pointer.consumeAllChanges 19 | import androidx.compose.ui.input.pointer.pointerInput 20 | import androidx.compose.ui.layout.onSizeChanged 21 | import androidx.compose.ui.platform.LocalDensity 22 | import androidx.compose.ui.res.painterResource 23 | import androidx.compose.ui.unit.IntSize 24 | import androidx.compose.ui.unit.dp 25 | import com.github.skgmn.composetooltip.AnchorEdge 26 | import com.github.skgmn.composetooltip.EdgePosition 27 | import com.github.skgmn.composetooltip.Tooltip 28 | import kotlinx.coroutines.flow.dropWhile 29 | import kotlinx.coroutines.flow.firstOrNull 30 | 31 | @OptIn(ExperimentalAnimationApi::class) 32 | @Composable 33 | fun ExamplePopup( 34 | contentPaddings: PaddingValues, 35 | scaffoldState: ScaffoldState 36 | ) { 37 | Box( 38 | modifier = Modifier 39 | .padding(contentPaddings) 40 | .fillMaxSize() 41 | ) { 42 | var anchorEdge by remember { mutableStateOf(AnchorEdge.Top) } 43 | 44 | var imageOffsetX by remember { mutableStateOf(0.dp) } 45 | var imageOffsetY by remember { mutableStateOf(0.dp) } 46 | 47 | val tipPosition = remember { EdgePosition() } 48 | val anchorPosition = remember { EdgePosition() } 49 | 50 | var tooltipVisible by remember { mutableStateOf(false) } 51 | 52 | LaunchedEffect(Unit) { 53 | snapshotFlow { scaffoldState.drawerState.isClosed } 54 | .dropWhile { !it } 55 | .firstOrNull() 56 | tooltipVisible = true 57 | } 58 | 59 | BottomPanel(tipPosition, anchorPosition) 60 | 61 | Column( 62 | modifier = Modifier 63 | .align(Alignment.Center) 64 | .offset(imageOffsetX, imageOffsetY) 65 | ) { 66 | Text( 67 | text = "⬆", 68 | modifier = Modifier 69 | .align(Alignment.CenterHorizontally) 70 | .padding(bottom = 8.dp) 71 | .buttonInvisible(tooltipVisible && anchorEdge == AnchorEdge.Top) { 72 | anchorEdge = AnchorEdge.Top 73 | tooltipVisible = true 74 | } 75 | .padding(8.dp) 76 | ) 77 | Row( 78 | verticalAlignment = Alignment.CenterVertically 79 | ) { 80 | Text( 81 | text = "⬅", 82 | modifier = Modifier 83 | .padding(end = 8.dp) 84 | .buttonInvisible(tooltipVisible && anchorEdge == AnchorEdge.Start) { 85 | anchorEdge = AnchorEdge.Start 86 | tooltipVisible = true 87 | } 88 | .padding(8.dp) 89 | ) 90 | val density = LocalDensity.current 91 | Box( 92 | modifier = Modifier 93 | .size(64.dp) 94 | .background(Color(0xffff0000)) 95 | .pointerInput(Unit) { 96 | detectDragGestures { change, dragAmount -> 97 | change.consumeAllChanges() 98 | with(density) { 99 | imageOffsetX += dragAmount.x.toDp() 100 | imageOffsetY += dragAmount.y.toDp() 101 | } 102 | } 103 | } 104 | ) { 105 | Image( 106 | painter = painterResource(R.drawable.dummy), 107 | contentDescription = "dummy", 108 | ) 109 | Tooltip( 110 | anchorEdge = anchorEdge, 111 | visible = tooltipVisible, 112 | enterTransition = fadeIn(), 113 | exitTransition = fadeOut(), 114 | tipPosition = tipPosition, 115 | anchorPosition = anchorPosition, 116 | modifier = Modifier.clickable( 117 | remember { MutableInteractionSource() }, 118 | null 119 | ) { 120 | tooltipVisible = false 121 | }, 122 | onDismissRequest = { 123 | tooltipVisible = false 124 | } 125 | ) { 126 | Text( 127 | text = "Drag icon to move it.\nTouch tooltip to dismiss it.\nTouch arrows to move anchor edge.", 128 | modifier = Modifier.widthIn(max = 200.dp) 129 | ) 130 | } 131 | } 132 | Text( 133 | text = "➡", 134 | modifier = Modifier 135 | .padding(start = 8.dp) 136 | .buttonInvisible(tooltipVisible && anchorEdge == AnchorEdge.End) { 137 | anchorEdge = AnchorEdge.End 138 | tooltipVisible = true 139 | } 140 | .padding(8.dp) 141 | ) 142 | } 143 | Text( 144 | text = "⬇", 145 | modifier = Modifier 146 | .align(Alignment.CenterHorizontally) 147 | .padding(top = 8.dp) 148 | .buttonInvisible(tooltipVisible && anchorEdge == AnchorEdge.Bottom) { 149 | anchorEdge = AnchorEdge.Bottom 150 | tooltipVisible = true 151 | } 152 | .padding(8.dp) 153 | ) 154 | } 155 | } 156 | } 157 | 158 | @Composable 159 | private fun BoxScope.BottomPanel( 160 | tipPosition: EdgePosition, 161 | anchorPosition: EdgePosition 162 | ) { 163 | val firstColumnWeight = 0.3f 164 | Column( 165 | modifier = Modifier 166 | .align(Alignment.BottomCenter) 167 | .fillMaxWidth() 168 | ) { 169 | Row( 170 | modifier = Modifier 171 | .fillMaxWidth() 172 | .padding(start = 16.dp), 173 | verticalAlignment = Alignment.CenterVertically 174 | ) { 175 | Text( 176 | text = "Tip position", 177 | modifier = Modifier.weight(firstColumnWeight) 178 | ) 179 | Slider( 180 | value = tipPosition.percent, 181 | onValueChange = { tipPosition.percent = it }, 182 | modifier = Modifier.weight(1f - firstColumnWeight) 183 | ) 184 | } 185 | Row( 186 | modifier = Modifier 187 | .fillMaxWidth() 188 | .padding(start = 16.dp), 189 | verticalAlignment = Alignment.CenterVertically 190 | ) { 191 | Text( 192 | text = "Anchor position", 193 | modifier = Modifier.weight(firstColumnWeight) 194 | ) 195 | Slider( 196 | value = anchorPosition.percent, 197 | onValueChange = { anchorPosition.percent = it }, 198 | modifier = Modifier.weight(1f - firstColumnWeight) 199 | ) 200 | } 201 | } 202 | } 203 | 204 | private fun Modifier.buttonInvisible(condition: Boolean, onClick: () -> Unit): Modifier { 205 | return if (condition) { 206 | alpha(0f) 207 | } else { 208 | clickable(onClick = onClick) 209 | } 210 | } -------------------------------------------------------------------------------- /sample/src/main/java/com/github/skgmn/composetooltip/sample/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.github.skgmn.composetooltip.sample 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.compose.material.MaterialTheme 7 | import androidx.compose.material.Surface 8 | import androidx.compose.material.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.tooling.preview.Preview 11 | import com.github.skgmn.composetooltip.sample.ui.theme.ComposeTooltipTheme 12 | 13 | class MainActivity : ComponentActivity() { 14 | override fun onCreate(savedInstanceState: Bundle?) { 15 | super.onCreate(savedInstanceState) 16 | setContent { 17 | ComposeTooltipTheme { 18 | MainScreen() 19 | } 20 | } 21 | } 22 | } 23 | 24 | @Preview(showSystemUi = true) 25 | @Composable 26 | fun MainScreenPreview() { 27 | ComposeTooltipTheme { 28 | MainScreen() 29 | } 30 | } -------------------------------------------------------------------------------- /sample/src/main/java/com/github/skgmn/composetooltip/sample/MainScreen.kt: -------------------------------------------------------------------------------- 1 | package com.github.skgmn.composetooltip.sample 2 | 3 | import androidx.compose.foundation.clickable 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material.* 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.filled.Menu 8 | import androidx.compose.runtime.* 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.unit.dp 11 | import kotlinx.coroutines.CoroutineScope 12 | import kotlinx.coroutines.launch 13 | import java.lang.IllegalStateException 14 | 15 | private const val EXAMPLE_CONSTRAINT_LAYOUT = 0 16 | private const val EXAMPLE_POPUP = 1 17 | 18 | @Composable 19 | fun MainScreen() { 20 | val scaffoldState = rememberScaffoldState() 21 | val scope = rememberCoroutineScope() 22 | var exampleType by remember { mutableStateOf(EXAMPLE_CONSTRAINT_LAYOUT) } 23 | Scaffold( 24 | scaffoldState = scaffoldState, 25 | topBar = { 26 | TopAppBar( 27 | title = { 28 | Text( 29 | when (exampleType) { 30 | EXAMPLE_CONSTRAINT_LAYOUT -> "Tooltip with ConstraintLayout" 31 | EXAMPLE_POPUP -> "Tooltip as Popup" 32 | else -> throw IllegalStateException() 33 | } 34 | ) 35 | }, 36 | navigationIcon = { 37 | IconButton(onClick = { toggleDrawer(scaffoldState, scope) }) { 38 | Icon( 39 | imageVector = Icons.Default.Menu, 40 | contentDescription = "Menu" 41 | ) 42 | } 43 | } 44 | ) 45 | }, 46 | drawerContent = { 47 | Text( 48 | text = "Tooltip with ConstraintLayout", 49 | modifier = Modifier 50 | .fillMaxWidth() 51 | .clickable { 52 | toggleDrawer(scaffoldState, scope) 53 | exampleType = EXAMPLE_CONSTRAINT_LAYOUT 54 | } 55 | .padding(16.dp) 56 | ) 57 | Text( 58 | text = "Tooltip as Popup", 59 | modifier = Modifier 60 | .fillMaxWidth() 61 | .clickable { 62 | toggleDrawer(scaffoldState, scope) 63 | exampleType = EXAMPLE_POPUP 64 | } 65 | .padding(16.dp) 66 | ) 67 | } 68 | ) { 69 | when (exampleType) { 70 | EXAMPLE_CONSTRAINT_LAYOUT -> ExampleConstraintLayout(contentPaddings = it) 71 | EXAMPLE_POPUP -> ExamplePopup(contentPaddings = it, scaffoldState = scaffoldState) 72 | } 73 | } 74 | } 75 | 76 | private fun toggleDrawer( 77 | scaffoldState: ScaffoldState, 78 | scope: CoroutineScope 79 | ) { 80 | if (!scaffoldState.drawerState.isAnimationRunning) { 81 | scope.launch { 82 | if (scaffoldState.drawerState.isClosed) { 83 | scaffoldState.drawerState.open() 84 | } else { 85 | scaffoldState.drawerState.close() 86 | } 87 | } 88 | } 89 | } -------------------------------------------------------------------------------- /sample/src/main/java/com/github/skgmn/composetooltip/sample/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.github.skgmn.composetooltip.sample.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple200 = Color(0xFFBB86FC) 6 | val Purple500 = Color(0xFF6200EE) 7 | val Purple700 = Color(0xFF3700B3) 8 | val Teal200 = Color(0xFF03DAC5) -------------------------------------------------------------------------------- /sample/src/main/java/com/github/skgmn/composetooltip/sample/ui/theme/Shape.kt: -------------------------------------------------------------------------------- 1 | package com.github.skgmn.composetooltip.sample.ui.theme 2 | 3 | import androidx.compose.foundation.shape.RoundedCornerShape 4 | import androidx.compose.material.Shapes 5 | import androidx.compose.ui.unit.dp 6 | 7 | val Shapes = Shapes( 8 | small = RoundedCornerShape(4.dp), 9 | medium = RoundedCornerShape(4.dp), 10 | large = RoundedCornerShape(0.dp) 11 | ) -------------------------------------------------------------------------------- /sample/src/main/java/com/github/skgmn/composetooltip/sample/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.github.skgmn.composetooltip.sample.ui.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material.MaterialTheme 5 | import androidx.compose.material.darkColors 6 | import androidx.compose.material.lightColors 7 | import androidx.compose.runtime.Composable 8 | 9 | private val DarkColorPalette = darkColors( 10 | primary = Purple200, 11 | primaryVariant = Purple700, 12 | secondary = Teal200 13 | ) 14 | 15 | private val LightColorPalette = lightColors( 16 | primary = Purple500, 17 | primaryVariant = Purple700, 18 | secondary = Teal200 19 | 20 | /* Other default colors to override 21 | background = Color.White, 22 | surface = Color.White, 23 | onPrimary = Color.White, 24 | onSecondary = Color.Black, 25 | onBackground = Color.Black, 26 | onSurface = Color.Black, 27 | */ 28 | ) 29 | 30 | @Composable 31 | fun ComposeTooltipTheme( 32 | darkTheme: Boolean = isSystemInDarkTheme(), 33 | content: @Composable() () -> Unit 34 | ) { 35 | val colors = if (darkTheme) { 36 | DarkColorPalette 37 | } else { 38 | LightColorPalette 39 | } 40 | 41 | MaterialTheme( 42 | colors = colors, 43 | typography = Typography, 44 | shapes = Shapes, 45 | content = content 46 | ) 47 | } -------------------------------------------------------------------------------- /sample/src/main/java/com/github/skgmn/composetooltip/sample/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.github.skgmn.composetooltip.sample.ui.theme 2 | 3 | import androidx.compose.material.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | body1 = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp 15 | ) 16 | /* Other default text styles to override 17 | button = TextStyle( 18 | fontFamily = FontFamily.Default, 19 | fontWeight = FontWeight.W500, 20 | fontSize = 14.sp 21 | ), 22 | caption = TextStyle( 23 | fontFamily = FontFamily.Default, 24 | fontWeight = FontWeight.Normal, 25 | fontSize = 12.sp 26 | ) 27 | */ 28 | ) -------------------------------------------------------------------------------- /sample/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable-xxxhdpi/dummy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skgmn/ComposeTooltip/7c4783308f55d3376039952e3dea5fdb6a5185c0/sample/src/main/res/drawable-xxxhdpi/dummy.png -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skgmn/ComposeTooltip/7c4783308f55d3376039952e3dea5fdb6a5185c0/sample/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skgmn/ComposeTooltip/7c4783308f55d3376039952e3dea5fdb6a5185c0/sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skgmn/ComposeTooltip/7c4783308f55d3376039952e3dea5fdb6a5185c0/sample/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skgmn/ComposeTooltip/7c4783308f55d3376039952e3dea5fdb6a5185c0/sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skgmn/ComposeTooltip/7c4783308f55d3376039952e3dea5fdb6a5185c0/sample/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skgmn/ComposeTooltip/7c4783308f55d3376039952e3dea5fdb6a5185c0/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skgmn/ComposeTooltip/7c4783308f55d3376039952e3dea5fdb6a5185c0/sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skgmn/ComposeTooltip/7c4783308f55d3376039952e3dea5fdb6a5185c0/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skgmn/ComposeTooltip/7c4783308f55d3376039952e3dea5fdb6a5185c0/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skgmn/ComposeTooltip/7c4783308f55d3376039952e3dea5fdb6a5185c0/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /sample/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /sample/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /sample/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | ComposeTooltip 3 | -------------------------------------------------------------------------------- /sample/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 17 | 21 | 22 |