All Downloads are FREE. Search and download functionalities are using the official Maven repository.

net.peanuuutz.fork.ui.preset.TextField.kt Maven / Gradle / Ivy

The newest version!
/*
 * Copyright 2020 The Android Open Source Project
 * Modifications Copyright 2022 Peanuuutz
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package net.peanuuutz.fork.ui.preset

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import net.peanuuutz.fork.ui.foundation.draw.BorderStroke
import net.peanuuutz.fork.ui.foundation.draw.background
import net.peanuuutz.fork.ui.foundation.draw.border
import net.peanuuutz.fork.ui.foundation.input.ContentScrollMode
import net.peanuuutz.fork.ui.foundation.input.ContentScrollState
import net.peanuuutz.fork.ui.foundation.input.hoverable
import net.peanuuutz.fork.ui.foundation.input.interaction.MutableInteractionSource
import net.peanuuutz.fork.ui.foundation.input.interaction.collectFocusState
import net.peanuuutz.fork.ui.foundation.input.interaction.collectHoverState
import net.peanuuutz.fork.ui.foundation.input.rememberContentScrollState
import net.peanuuutz.fork.ui.foundation.layout.Box
import net.peanuuutz.fork.ui.foundation.layout.PaddingValues
import net.peanuuutz.fork.ui.foundation.layout.minSize
import net.peanuuutz.fork.ui.foundation.text.LocalTextStyle
import net.peanuuutz.fork.ui.foundation.text.TextLayoutResult
import net.peanuuutz.fork.ui.foundation.text.TextSelectionStyle
import net.peanuuutz.fork.ui.foundation.text.field.BasicTextField
import net.peanuuutz.fork.ui.foundation.text.field.CursorStyle
import net.peanuuutz.fork.ui.foundation.text.field.TextFieldWriter
import net.peanuuutz.fork.ui.foundation.text.field.rememberTextFieldWriter
import net.peanuuutz.fork.ui.preset.TextFieldComponent.LeadingIcon
import net.peanuuutz.fork.ui.preset.TextFieldComponent.Text
import net.peanuuutz.fork.ui.preset.TextFieldComponent.TrailingIcon
import net.peanuuutz.fork.ui.preset.theme.Theme
import net.peanuuutz.fork.ui.ui.draw.Painter
import net.peanuuutz.fork.ui.ui.draw.text.Paragraph
import net.peanuuutz.fork.ui.ui.draw.text.TextStyle
import net.peanuuutz.fork.ui.ui.layout.Alignment
import net.peanuuutz.fork.ui.ui.layout.Layout
import net.peanuuutz.fork.ui.ui.layout.Measurable
import net.peanuuutz.fork.ui.ui.layout.MeasurePolicy
import net.peanuuutz.fork.ui.ui.layout.MeasureResult
import net.peanuuutz.fork.ui.ui.layout.constrainHeight
import net.peanuuutz.fork.ui.ui.layout.constrainWidth
import net.peanuuutz.fork.ui.ui.layout.offset
import net.peanuuutz.fork.ui.ui.modifier.Modifier
import net.peanuuutz.fork.ui.ui.modifier.layout.layoutId
import net.peanuuutz.fork.ui.ui.unit.IntSize
import net.peanuuutz.fork.util.common.Color
import net.peanuuutz.fork.util.common.fastForEach
import net.peanuuutz.fork.util.common.isUnspecified

@Composable
fun TextField(
    text: String,
    onCandidate: (String) -> Boolean,
    modifier: Modifier = Modifier,
    writer: TextFieldWriter = rememberTextFieldWriter(),
    textStyle: TextStyle = LocalTextStyle.current,
    isEnabled: Boolean = true,
    isReadOnly: Boolean = false,
    isSingleLine: Boolean = true,
    hasError: Boolean = false,
    textFieldStyle: TextFieldStyle = Theme.textField,
    textPadding: PaddingValues = TextFieldDefaults.TextPadding,
    onTextLayout: ((result: TextLayoutResult) -> Unit)? = null,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    contentScrollState: ContentScrollState = rememberContentScrollState(),
    contentScrollMode: ContentScrollMode = ContentScrollMode.Default,
    placeholder: (@Composable () -> Unit)? = null,
    leadingIcon: (@Composable () -> Unit)? = null,
    trailingIcon: (@Composable () -> Unit)? = null,
    decoration: @Composable (field: @Composable () -> Unit) -> Unit = { field ->
        TextFieldDefaults.Decoration(
            field = field,
            isEnabled = isEnabled,
            isSingleLine = isSingleLine,
            hasError = hasError,
            textFieldStyle = textFieldStyle,
            textPadding = textPadding,
            placeholder = placeholder,
            leadingIcon = leadingIcon,
            trailingIcon = trailingIcon,
            shouldDisplayPlaceHolder = text.isEmpty()
        )
    }
) {
    var backedParagraph by remember { mutableStateOf(Paragraph.Empty) }
    val displayParagraph = backedParagraph.copy(plainText = text)

    TextField(
        paragraph = displayParagraph,
        onCandidate = { newValue ->
            backedParagraph = newValue
            val newPlainText = newValue.plainText
            newPlainText != text && onCandidate(newPlainText)
        },
        modifier = modifier,
        writer = writer,
        textStyle = textStyle,
        isEnabled = isEnabled,
        isReadOnly = isReadOnly,
        isSingleLine = isSingleLine,
        hasError = hasError,
        textFieldStyle = textFieldStyle,
        textPadding = textPadding,
        onTextLayout = onTextLayout,
        interactionSource = interactionSource,
        contentScrollState = contentScrollState,
        contentScrollMode = contentScrollMode,
        placeholder = placeholder,
        leadingIcon = leadingIcon,
        trailingIcon = trailingIcon,
        decoration = decoration
    )
}

@Composable
fun TextField(
    paragraph: Paragraph,
    onCandidate: (Paragraph) -> Boolean,
    modifier: Modifier = Modifier,
    writer: TextFieldWriter = rememberTextFieldWriter(),
    textStyle: TextStyle = LocalTextStyle.current,
    isEnabled: Boolean = true,
    isReadOnly: Boolean = false,
    isSingleLine: Boolean = true,
    hasError: Boolean = false,
    textFieldStyle: TextFieldStyle = Theme.textField,
    textPadding: PaddingValues = TextFieldDefaults.TextPadding,
    onTextLayout: ((result: TextLayoutResult) -> Unit)? = null,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    contentScrollState: ContentScrollState = rememberContentScrollState(),
    contentScrollMode: ContentScrollMode = ContentScrollMode.Default,
    placeholder: (@Composable () -> Unit)? = null,
    leadingIcon: (@Composable () -> Unit)? = null,
    trailingIcon: (@Composable () -> Unit)? = null,
    decoration: @Composable (field: @Composable () -> Unit) -> Unit = { field ->
        TextFieldDefaults.Decoration(
            field = field,
            isEnabled = isEnabled,
            isSingleLine = isSingleLine,
            hasError = hasError,
            textFieldStyle = textFieldStyle,
            textPadding = textPadding,
            placeholder = placeholder,
            leadingIcon = leadingIcon,
            trailingIcon = trailingIcon,
            shouldDisplayPlaceHolder = paragraph.plainText.isEmpty()
        )
    }
) {
    val isHovered by interactionSource.collectHoverState()
    val isFocused by interactionSource.collectFocusState()
    val isSelected = isHovered || isFocused
    val border by textFieldStyle.border(
        isEnabled = isEnabled,
        isSelected = isSelected,
        hasError = hasError
    )
    val background by textFieldStyle.background(
        isEnabled = isEnabled,
        isSelected = isSelected,
        hasError = hasError
    )
    val actualTextStyle = if (textStyle.color.isUnspecified()) {
        val textColor by textFieldStyle.text(
            isEnabled = isEnabled,
            hasError = hasError
        )
        textStyle.copy(color = textColor)
    } else {
        textStyle
    }
    val selectionStyle by textFieldStyle.selection()
    val cursorStyle by textFieldStyle.cursor(hasError = hasError)
    writer.selectionStyle = selectionStyle
    writer.cursorStyle = cursorStyle

    BasicTextField(
        paragraph = paragraph,
        onCandidate = onCandidate,
        modifier = modifier
            .minSize(TextFieldDefaults.MinSize)
            .hoverable(
                interactionSource = interactionSource,
                isEnabled = isEnabled
            )
            .border(border)
            .background(background),
        writer = writer,
        textStyle = actualTextStyle,
        isEnabled = isEnabled,
        isReadOnly = isReadOnly,
        isSingleLine = isSingleLine,
        onTextLayout = onTextLayout,
        interactionSource = interactionSource,
        contentScrollState = contentScrollState,
        contentScrollMode = contentScrollMode,
        decoration = decoration
    )
}

object TextFieldDefaults {
    val MinSize: IntSize = IntSize(20, 12)

    const val DefaultHeight: Int = 20

    val TextPadding: PaddingValues = PaddingValues(4, 1)

    @Composable
    fun Decoration(
        field: @Composable () -> Unit,
        isEnabled: Boolean = true,
        isSingleLine: Boolean = true,
        hasError: Boolean = false,
        textFieldStyle: TextFieldStyle = Theme.textField,
        textPadding: PaddingValues = TextPadding,
        placeholder: (@Composable () -> Unit)? = null,
        leadingIcon: (@Composable () -> Unit)? = null,
        trailingIcon: (@Composable () -> Unit)? = null,
        shouldDisplayPlaceHolder: Boolean = false
    ) {
        val measurePolicy = rememberTextFieldMeasurePolicy(
            isSingleLine = isSingleLine,
            textPadding = textPadding
        )

        Layout(
            content = {
                val placeholderColor by textFieldStyle.placeholder(isEnabled = isEnabled)
                val leadingIconColor by textFieldStyle.leadingIcon(
                    isEnabled = isEnabled,
                    hasError = hasError
                )
                val trailingIconColor by textFieldStyle.trailingIcon(
                    isEnabled = isEnabled,
                    hasError = hasError
                )

                if (leadingIcon != null) {
                    Box(
                        modifier = Modifier.layoutId(LeadingIcon)
                    ) {
                        SingleDecoration(
                            color = leadingIconColor,
                            content = leadingIcon
                        )
                    }
                }

                Box(
                    modifier = Modifier.layoutId(Text)
                ) {
                    if (shouldDisplayPlaceHolder && placeholder != null) {
                        SingleDecoration(
                            color = placeholderColor,
                            content = placeholder
                        )
                    }

                    field()
                }

                if (trailingIcon != null) {
                    Box(
                        modifier = Modifier.layoutId(TrailingIcon)
                    ) {
                        SingleDecoration(
                            color = trailingIconColor,
                            content = trailingIcon
                        )
                    }
                }
            },
            measurePolicy = measurePolicy
        )
    }
}

@Stable
interface TextFieldStyle {
    @Composable
    fun border(
        isEnabled: Boolean,
        isSelected: Boolean,
        hasError: Boolean
    ): State

    @Composable
    fun background(
        isEnabled: Boolean,
        isSelected: Boolean,
        hasError: Boolean
    ): State

    @Composable
    fun text(
        isEnabled: Boolean,
        hasError: Boolean
    ): State

    @Composable
    fun cursor(hasError: Boolean): State

    @Composable
    fun selection(): State

    @Composable
    fun placeholder(isEnabled: Boolean): State

    @Composable
    fun leadingIcon(
        isEnabled: Boolean,
        hasError: Boolean
    ): State

    @Composable
    fun trailingIcon(
        isEnabled: Boolean,
        hasError: Boolean
    ): State

    @Stable
    abstract class Delegated(
        val delegate: TextFieldStyle
    ) : TextFieldStyle by delegate
}

val Theme.textField: TextFieldStyle
    @Composable
    @ReadOnlyComposable
    get() = LocalTextField.current

@NonRestartableComposable
@Composable
fun TextFieldStyleProvider(
    textFieldStyle: TextFieldStyle,
    content: @Composable () -> Unit
) {
    CompositionLocalProvider(
        LocalTextField provides textFieldStyle,
        content = content
    )
}

@Stable
class DefaultTextFieldStyle(
    val border: BorderStroke,
    val background: Painter,
    val textColor: Color,
    val cursorStyle: CursorStyle,
    val leadingIconColor: Color,
    val trailingIconColor: Color,
    val placeholderColor: Color,
    val selectedBorder: BorderStroke,
    val selectedBackground: Painter,
    val errorBorder: BorderStroke,
    val errorBackground: Painter,
    val errorTextColor: Color,
    val errorCursorStyle: CursorStyle,
    val errorLeadingIconColor: Color,
    val errorTrailingIconColor: Color,
    val disabledBorder: BorderStroke,
    val disabledBackground: Painter,
    val disabledTextColor: Color,
    val disabledLeadingIconColor: Color,
    val disabledTrailingIconColor: Color,
    val disabledPlaceholderColor: Color,
    val selectionStyle: TextSelectionStyle
) : TextFieldStyle {
    @Composable
    override fun border(isEnabled: Boolean, isSelected: Boolean, hasError: Boolean): State {
        val borderStroke = when {
            !isEnabled -> disabledBorder
            hasError -> errorBorder
            !isSelected -> border
            else -> selectedBorder
        }
        return rememberUpdatedState(borderStroke)
    }

    @Composable
    override fun background(isEnabled: Boolean, isSelected: Boolean, hasError: Boolean): State {
        val painter = when {
            !isEnabled -> disabledBackground
            hasError -> errorBackground
            !isSelected -> background
            else -> selectedBackground
        }
        return rememberUpdatedState(painter)
    }

    @Composable
    override fun text(isEnabled: Boolean, hasError: Boolean): State {
        val color = when {
            !isEnabled -> disabledTextColor
            hasError -> errorTextColor
            else -> textColor
        }
        return rememberUpdatedState(color)
    }

    @Composable
    override fun cursor(hasError: Boolean): State {
        val cursorStyle = if (hasError) errorCursorStyle else cursorStyle
        return rememberUpdatedState(cursorStyle)
    }

    @Composable
    override fun selection(): State {
        return rememberUpdatedState(selectionStyle)
    }

    @Composable
    override fun placeholder(isEnabled: Boolean): State {
        val color = if (isEnabled) placeholderColor else disabledPlaceholderColor
        return rememberUpdatedState(color)
    }

    @Composable
    override fun leadingIcon(isEnabled: Boolean, hasError: Boolean): State {
        val color = when {
            !isEnabled -> disabledLeadingIconColor
            hasError -> errorLeadingIconColor
            else -> leadingIconColor
        }
        return rememberUpdatedState(color)
    }

    @Composable
    override fun trailingIcon(isEnabled: Boolean, hasError: Boolean): State {
        val color = when {
            !isEnabled -> disabledTrailingIconColor
            hasError -> errorTrailingIconColor
            else -> trailingIconColor
        }
        return rememberUpdatedState(color)
    }

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is DefaultTextFieldStyle) return false
        if (border != other.border) return false
        if (background != other.background) return false
        if (textColor != other.textColor) return false
        if (cursorStyle != other.cursorStyle) return false
        if (leadingIconColor != other.leadingIconColor) return false
        if (trailingIconColor != other.trailingIconColor) return false
        if (placeholderColor != other.placeholderColor) return false
        if (selectedBorder != other.selectedBorder) return false
        if (selectedBackground != other.selectedBackground) return false
        if (errorBorder != other.errorBorder) return false
        if (errorBackground != other.errorBackground) return false
        if (errorTextColor != other.errorTextColor) return false
        if (errorCursorStyle != other.errorCursorStyle) return false
        if (errorLeadingIconColor != other.errorLeadingIconColor) return false
        if (errorTrailingIconColor != other.errorTrailingIconColor) return false
        if (disabledBorder != other.disabledBorder) return false
        if (disabledBackground != other.disabledBackground) return false
        if (disabledTextColor != other.disabledTextColor) return false
        if (disabledLeadingIconColor != other.disabledLeadingIconColor) return false
        if (disabledTrailingIconColor != other.disabledTrailingIconColor) return false
        if (disabledPlaceholderColor != other.disabledPlaceholderColor) return false
        if (selectionStyle != other.selectionStyle) return false
        return true
    }

    override fun hashCode(): Int {
        var result = border.hashCode()
        result = 31 * result + background.hashCode()
        result = 31 * result + textColor.hashCode()
        result = 31 * result + cursorStyle.hashCode()
        result = 31 * result + leadingIconColor.hashCode()
        result = 31 * result + trailingIconColor.hashCode()
        result = 31 * result + placeholderColor.hashCode()
        result = 31 * result + selectedBorder.hashCode()
        result = 31 * result + selectedBackground.hashCode()
        result = 31 * result + errorBorder.hashCode()
        result = 31 * result + errorBackground.hashCode()
        result = 31 * result + errorTextColor.hashCode()
        result = 31 * result + errorCursorStyle.hashCode()
        result = 31 * result + errorLeadingIconColor.hashCode()
        result = 31 * result + errorTrailingIconColor.hashCode()
        result = 31 * result + disabledBorder.hashCode()
        result = 31 * result + disabledBackground.hashCode()
        result = 31 * result + disabledTextColor.hashCode()
        result = 31 * result + disabledLeadingIconColor.hashCode()
        result = 31 * result + disabledTrailingIconColor.hashCode()
        result = 31 * result + disabledPlaceholderColor.hashCode()
        result = 31 * result + selectionStyle.hashCode()
        return result
    }

    override fun toString(): String {
        return "DefaultTextFieldStyle(border=$border, background=$background, " +
                "textColor=$textColor, cursorStyle=$cursorStyle, " +
                "leadingIconColor=$leadingIconColor, trailingIconColor=$trailingIconColor, " +
                "placeholderColor=$placeholderColor, " +
                "selectedBorder=$selectedBorder, selectedBackground=$selectedBackground, " +
                "errorBorder=$errorBorder, errorBackground=$errorBackground, " +
                "errorTextColor=$errorTextColor, errorCursorStyle=$errorCursorStyle, " +
                "errorLeadingIconColor=$errorLeadingIconColor, " +
                "errorTrailingIconColor=$errorTrailingIconColor, " +
                "disabledBorder=$disabledBorder, disabledBackground=$disabledBackground, " +
                "disabledTextColor=$disabledTextColor, " +
                "disabledLeadingIconColor=$disabledLeadingIconColor, " +
                "disabledTrailingIconColor=$disabledTrailingIconColor, " +
                "disabledPlaceholderColor=$disabledPlaceholderColor, " +
                "selectionStyle=$selectionStyle)"
    }
}

// ======== Internal ========

@NonRestartableComposable
@Composable
private fun SingleDecoration(
    color: Color,
    content: @Composable () -> Unit
) {
    CompositionLocalProvider(
        LocalContentColor provides color,
        content = content
    )
}

@Composable
private fun rememberTextFieldMeasurePolicy(
    isSingleLine: Boolean,
    textPadding: PaddingValues
): MeasurePolicy {
    return remember(isSingleLine, textPadding) {
        MeasurePolicy { measurables, constraints ->
            require(measurables.size in 1..3) {
                "Cannot apply TextFieldMeasurePolicy to other Layout"
            }
            val left = textPadding.calculateLeftPadding()
            val top = textPadding.calculateTopPadding()
            val right = textPadding.calculateRightPadding()
            val bottom = textPadding.calculateBottomPadding()
            require(left >= 0 && top >= 0 && right >= 0 && bottom >= 0) {
                "Cannot have negative padding, but found $textPadding"
            }
            var leadingIcon: Measurable? = null
            lateinit var text: Measurable
            var trailingIcon: Measurable? = null
            measurables.fastForEach { measurable ->
                when (measurable.layoutId) {
                    LeadingIcon -> leadingIcon = measurable
                    Text -> text = measurable
                    TrailingIcon -> trailingIcon = measurable
                }
            }
            val decorationConstraints = constraints.copy(minWidth = 0, minHeight = 0)
            var occupiedWidth = 0
            val leadingIconPlaceable = leadingIcon?.measure(decorationConstraints)
            occupiedWidth += leadingIconPlaceable?.width ?: 0
            val trailingIconPlaceable = trailingIcon?.measure(decorationConstraints.offset(horizontal = -occupiedWidth))
            occupiedWidth += trailingIconPlaceable?.width ?: 0
            val horizontalPadding = left + right
            val verticalPadding = top + bottom
            val textConstraints = constraints
                .copy(minHeight = 0)
                .offset(
                    horizontal = -(occupiedWidth + horizontalPadding),
                    vertical = -verticalPadding
                )
            val textPlaceable = text.measure(textConstraints)
            val plainWidth = occupiedWidth + textPlaceable.width + horizontalPadding
            val plainHeight = maxOf(
                a = leadingIconPlaceable?.height ?: 0,
                b = textPlaceable.height + verticalPadding,
                c = trailingIconPlaceable?.height ?: 0
            )
            val width = constraints.constrainWidth(plainWidth)
            val height = constraints.constrainHeight(plainHeight)
            MeasureResult(width, height) {
                val textX = leadingIconPlaceable?.width ?: 0
                val textY = if (isSingleLine) {
                    alignComponent(
                        contentSize = textPlaceable.height,
                        availableSpace = height - verticalPadding
                    )
                } else {
                    0
                }
                leadingIconPlaceable?.place(
                    x = 0,
                    y = alignComponent(
                        contentSize = leadingIconPlaceable.height,
                        availableSpace = height
                    )
                )
                textPlaceable.place(
                    x = left + textX,
                    y = top + textY
                )
                trailingIconPlaceable?.place(
                    x = width - trailingIconPlaceable.width,
                    y = alignComponent(
                        contentSize = trailingIconPlaceable.height,
                        availableSpace = height
                    )
                )
            }
        }
    }
}

// TODO Custom?
private fun alignComponent(
    contentSize: Int,
    availableSpace: Int
): Int {
    return Alignment.CenterVertically.alignVertically(
        contentSize = contentSize,
        availableSpace = availableSpace
    )
}

private enum class TextFieldComponent {
    LeadingIcon,
    Text,
    TrailingIcon
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy