Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit c8b98571 authored by Gustavo Pagani's avatar Gustavo Pagani
Browse files

Add Horologist module and initial classes that are going to be needed for wear project.

Bug: 299958033
Test: N/A - project has automated tests in github

Change-Id: I6b7cea8041caa32109daeaa46ccc358452967d4a
parent 1b18c08d
Loading
Loading
Loading
Loading
+27 −0
Original line number Diff line number Diff line
package {
    // See: http://go/android-license-faq
    // A large-scale-change added 'default_applicable_licenses' to import
    // all of the 'license_kinds' from "frameworks_base_license"
    // to get the below license kinds:
    //   SPDX-license-identifier-Apache-2.0
    default_applicable_licenses: ["frameworks_base_license"],
}

// TODO: ag/24733147 - Remove this project once it is imported.
android_library {
    name: "Horologist",
    manifest: "AndroidManifest.xml",
    srcs: ["src/**/*.kt"],
    static_libs: [
        "androidx.compose.foundation_foundation",
        "androidx.compose.runtime_runtime",
        "androidx.compose.ui_ui",
        "androidx.navigation_navigation-compose",
        "androidx.lifecycle_lifecycle-extensions",
        "androidx.lifecycle_lifecycle-runtime-ktx",
        "androidx.lifecycle_lifecycle-viewmodel-compose",
        "androidx.wear.compose_compose-foundation",
        "androidx.wear.compose_compose-material",
        "androidx.wear.compose_compose-navigation",
    ],
}
+24 −0
Original line number Diff line number Diff line
<?xml version="1.0" encoding="utf-8"?>
<!--
/*
 * Copyright (c) 2023 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.google.android.horologist">

    <uses-feature android:name="android.hardware.type.watch" />

</manifest>
+23 −0
Original line number Diff line number Diff line
/*
 * Copyright 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.google.android.horologist.annotations

@RequiresOptIn(
        message = "Horologist API is experimental. The API may be changed in the future.",
)
@Retention(AnnotationRetention.BINARY)
public annotation class ExperimentalHorologistApi
+131 −0
Original line number Diff line number Diff line
/*
 * Copyright 2022 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

@file:Suppress("ObjectLiteralToLambda")

package com.google.android.horologist.compose.layout

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.lazy.AutoCenteringParams
import androidx.wear.compose.foundation.lazy.ScalingLazyListAnchorType
import com.google.android.horologist.annotations.ExperimentalHorologistApi
import com.google.android.horologist.compose.layout.ScalingLazyColumnState.RotaryMode

/**
 * Default layouts for ScalingLazyColumnState, based on UX guidance.
 */
public object ScalingLazyColumnDefaults {
    /**
     * Layout the first item, directly under the time text.
     * This is positioned from the top of the screen instead of the
     * center.
     */
    @ExperimentalHorologistApi
    public fun belowTimeText(
            rotaryMode: RotaryMode = RotaryMode.Scroll,
            firstItemIsFullWidth: Boolean = false,
            verticalArrangement: Arrangement.Vertical =
                    Arrangement.spacedBy(
                            space = 4.dp,
                            alignment = Alignment.Top,
                    ),
            horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
            contentPadding: PaddingValues = PaddingValues(horizontal = 10.dp),
            topPaddingDp: Dp = 32.dp + (if (firstItemIsFullWidth) 20.dp else 0.dp),
    ): ScalingLazyColumnState.Factory {
        return object : ScalingLazyColumnState.Factory {
            @Composable
            override fun create(): ScalingLazyColumnState {
                val density = LocalDensity.current
                val configuration = LocalConfiguration.current

                return remember {
                    val screenHeightPx =
                            with(density) { configuration.screenHeightDp.dp.roundToPx() }
                    val topPaddingPx = with(density) { topPaddingDp.roundToPx() }
                    val topScreenOffsetPx = screenHeightPx / 2 - topPaddingPx

                    ScalingLazyColumnState(
                            initialScrollPosition = ScalingLazyColumnState.ScrollPosition(
                                    index = 0,
                                    offsetPx = topScreenOffsetPx,
                            ),
                            anchorType = ScalingLazyListAnchorType.ItemStart,
                            rotaryMode = rotaryMode,
                            verticalArrangement = verticalArrangement,
                            horizontalAlignment = horizontalAlignment,
                            contentPadding = contentPadding,
                    )
                }
            }
        }
    }

    /**
     * Layout the item [initialCenterIndex] at [initialCenterOffset] from the
     * center of the screen.
     */
    @ExperimentalHorologistApi
    public fun scalingLazyColumnDefaults(
            rotaryMode: RotaryMode = RotaryMode.Scroll,
            initialCenterIndex: Int = 1,
            initialCenterOffset: Int = 0,
            verticalArrangement: Arrangement.Vertical =
                    Arrangement.spacedBy(
                            space = 4.dp,
                            alignment = Alignment.Top,
                    ),
            horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
            contentPadding: PaddingValues = PaddingValues(horizontal = 10.dp),
            autoCentering: AutoCenteringParams? = AutoCenteringParams(
                    initialCenterIndex,
                    initialCenterOffset,
            ),
            anchorType: ScalingLazyListAnchorType = ScalingLazyListAnchorType.ItemCenter,
            hapticsEnabled: Boolean = true,
            reverseLayout: Boolean = false,
    ): ScalingLazyColumnState.Factory {
        return object : ScalingLazyColumnState.Factory {
            @Composable
            override fun create(): ScalingLazyColumnState {
                return remember {
                    ScalingLazyColumnState(
                            initialScrollPosition = ScalingLazyColumnState.ScrollPosition(
                                    index = initialCenterIndex,
                                    offsetPx = initialCenterOffset,
                            ),
                            rotaryMode = rotaryMode,
                            verticalArrangement = verticalArrangement,
                            horizontalAlignment = horizontalAlignment,
                            contentPadding = contentPadding,
                            autoCentering = autoCentering,
                            anchorType = anchorType,
                            hapticsEnabled = hapticsEnabled,
                            reverseLayout = reverseLayout,
                    )
                }
            }
        }
    }
}
+168 −0
Original line number Diff line number Diff line
/*
 * Copyright 2022 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

@file:Suppress("ObjectLiteralToLambda")
@file:OptIn(ExperimentalHorologistApi::class, ExperimentalWearFoundationApi::class)

package com.google.android.horologist.compose.layout

import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
import androidx.wear.compose.foundation.lazy.AutoCenteringParams
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.ScalingLazyListAnchorType
import androidx.wear.compose.foundation.lazy.ScalingLazyListScope
import androidx.wear.compose.foundation.lazy.ScalingLazyListState
import androidx.wear.compose.foundation.lazy.ScalingParams
import androidx.wear.compose.foundation.rememberActiveFocusRequester
import com.google.android.horologist.annotations.ExperimentalHorologistApi
import com.google.android.horologist.compose.layout.ScalingLazyColumnState.RotaryMode
import com.google.android.horologist.compose.rotaryinput.rememberDisabledHaptic
import com.google.android.horologist.compose.rotaryinput.rememberRotaryHapticHandler
import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll
import com.google.android.horologist.compose.rotaryinput.rotaryWithSnap
import com.google.android.horologist.compose.rotaryinput.toRotaryScrollAdapter
import androidx.wear.compose.foundation.lazy.ScalingLazyColumnDefaults as WearScalingLazyColumnDefaults

/**
 * A Config and State object wrapping up all configuration for a [ScalingLazyColumn].
 * This allows defaults such as [ScalingLazyColumnDefaults.belowTimeText].
 */
@ExperimentalHorologistApi
public class ScalingLazyColumnState(
        public val initialScrollPosition: ScrollPosition = ScrollPosition(1, 0),
        public val autoCentering: AutoCenteringParams? = AutoCenteringParams(
                initialScrollPosition.index,
                initialScrollPosition.offsetPx,
        ),
        public val anchorType: ScalingLazyListAnchorType = ScalingLazyListAnchorType.ItemCenter,
        public val contentPadding: PaddingValues = PaddingValues(horizontal = 10.dp),
        public val rotaryMode: RotaryMode = RotaryMode.Scroll,
        public val reverseLayout: Boolean = false,
        public val verticalArrangement: Arrangement.Vertical =
                Arrangement.spacedBy(
                        space = 4.dp,
                        alignment = if (!reverseLayout) Alignment.Top else Alignment.Bottom,
                ),
        public val horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
        public val flingBehavior: FlingBehavior? = null,
        public val userScrollEnabled: Boolean = true,
        public val scalingParams: ScalingParams = WearScalingLazyColumnDefaults.scalingParams(),
        public val hapticsEnabled: Boolean = true,
) {
    private var _state: ScalingLazyListState? = null
    public var state: ScalingLazyListState
        get() {
            if (_state == null) {
                _state = ScalingLazyListState(
                        initialScrollPosition.index,
                        initialScrollPosition.offsetPx,
                )
            }
            return _state!!
        }
        set(value) {
            _state = value
        }

    public sealed interface RotaryMode {
        public object Snap : RotaryMode
        public object Scroll : RotaryMode

        @Deprecated(
                "Use RotaryMode.Scroll instead",
                replaceWith = ReplaceWith("RotaryMode.Scroll"),
        )
        public object Fling : RotaryMode
    }

    public data class ScrollPosition(
            val index: Int,
            val offsetPx: Int,
    )

    public fun interface Factory {
        @Composable
        public fun create(): ScalingLazyColumnState
    }
}

@Composable
public fun rememberColumnState(
        factory: ScalingLazyColumnState.Factory = ScalingLazyColumnDefaults.belowTimeText(),
): ScalingLazyColumnState {
    val columnState = factory.create()

    columnState.state = rememberSaveable(saver = ScalingLazyListState.Saver) {
        columnState.state
    }

    return columnState
}

@ExperimentalHorologistApi
@Composable
public fun ScalingLazyColumn(
        columnState: ScalingLazyColumnState,
        modifier: Modifier = Modifier,
        content: ScalingLazyListScope.() -> Unit,
) {
    val focusRequester = rememberActiveFocusRequester()

    val rotaryHaptics = if (columnState.hapticsEnabled) {
        rememberRotaryHapticHandler(columnState.state)
    } else {
        rememberDisabledHaptic()
    }
    val modifierWithRotary = when (columnState.rotaryMode) {
        RotaryMode.Snap -> modifier.rotaryWithSnap(
                focusRequester = focusRequester,
                rotaryScrollAdapter = columnState.state.toRotaryScrollAdapter(),
                reverseDirection = columnState.reverseLayout,
                rotaryHaptics = rotaryHaptics,
        )

        else -> modifier.rotaryWithScroll(
                focusRequester = focusRequester,
                scrollableState = columnState.state,
                reverseDirection = columnState.reverseLayout,
                rotaryHaptics = rotaryHaptics,
        )
    }

    ScalingLazyColumn(
            modifier = modifierWithRotary,
            state = columnState.state,
            contentPadding = columnState.contentPadding,
            reverseLayout = columnState.reverseLayout,
            verticalArrangement = columnState.verticalArrangement,
            horizontalAlignment = columnState.horizontalAlignment,
            flingBehavior = columnState.flingBehavior ?: ScrollableDefaults.flingBehavior(),
            userScrollEnabled = columnState.userScrollEnabled,
            scalingParams = columnState.scalingParams,
            anchorType = columnState.anchorType,
            autoCentering = columnState.autoCentering,
            content = content,
    )
}
Loading