Skip to content

Make android sheet reactive to height changes #192

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed

Conversation

adelbeke
Copy link

@adelbeke adelbeke commented Jul 1, 2025

This PR fixes an issue with dynamic content height updates when using the 'auto' size option on Android.

It fixes the #191 issue.

Issue

When using a TrueSheet with sizes={['auto']} and dynamically updating the content (e.g., conditional rendering after a delay), the sheet wouldn't resize properly on Android, even though it worked correctly on iOS.

Solution

Enhanced the content height handling in TrueSheetDialog.kt to properly update the sheet's dimensions when content changes:

  1. Added a setter for contentHeight that detects changes
  2. When using 'auto' size, directly updates the container's layout params
  3. Forces the behavior to reconfigure with the new height
  4. Ensures the sheet expands to the new height by setting the proper state

Testing

To test this fix:

  1. Create a sheet with sizes={['auto']}
  2. Add conditional content that renders after a delay
  3. Verify the sheet properly resizes when the content appears

Before this fix, the sheet would remain at its initial height. After the fix, it properly expands to fit the new content.

Preview

Before

before.mov

After

after.mov

Copy link

vercel bot commented Jul 1, 2025

@adelbeke is attempting to deploy a commit to the Jovanni's projects Team on Vercel.

A member of the Team first needs to authorize it.

Copy link

codeclimate bot commented Jul 1, 2025

Code Climate has analyzed commit aa93f92 and detected 0 issues on this pull request.

View more on Code Climate.

@lodev09
Copy link
Owner

lodev09 commented Jul 1, 2025

Thanks @adelbeke. is there a way we could animate the size change?

@adelbeke
Copy link
Author

adelbeke commented Jul 1, 2025

@lodev09 I'll try tonight 🤞🏻

@adelbeke
Copy link
Author

adelbeke commented Jul 1, 2025

@lodev09 I tried, but my knowledge of Kotlin is very limited. Do you see an easy way to do it?

@lovegaoshi
Copy link

lovegaoshi commented Jul 1, 2025

heres some relevant reading ; i think maxHeight is a hack, as in the documentation itself it says "This method should be called before Dialog.show() in order for the height to be adjusted as expected."

i'm having success with converting size === 1's bottomBehavior.State to BottomSheetBehavior.STATE_COLLAPSED instead of BottomSheetBehavior.STATE_EXPANDED, and use behavior.setPeekHeight instead of setMaxHeight and force a view layout recalculation. Well obviously only auto should be considered STATE_COLLAPSED and all others probably should be STATE_EXPANDED to limit maxsize. Though I wonder for people wanting to use maxheight, would size=['auto', "some %"] suffice?

the entire TrueSheetDialog.kt below

package com.lodev09.truesheet

import android.annotation.SuppressLint
import android.graphics.Color
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.RoundRectShape
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import com.facebook.react.uimanager.ThemedReactContext
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.lodev09.truesheet.core.KeyboardManager
import com.lodev09.truesheet.core.RootSheetView
import com.lodev09.truesheet.core.Utils

data class SizeInfo(val index: Int, val value: Float)

@SuppressLint("ClickableViewAccessibility")
class TrueSheetDialog(private val reactContext: ThemedReactContext, private val rootSheetView: RootSheetView) :
  BottomSheetDialog(reactContext) {

  private var keyboardManager = KeyboardManager(reactContext)
  private var windowAnimation: Int = 0

  // First child of the rootSheetView
  private val containerView: ViewGroup?
    get() = if (rootSheetView.childCount > 0) {
      rootSheetView.getChildAt(0) as? ViewGroup
    } else {
      null
    }

  private val sheetContainerView: ViewGroup?
    get() = rootSheetView.parent?.let { it as? ViewGroup }

  /**
   * Specify whether the sheet background is dimmed.
   * Set to `false` to allow interaction with the background components.
   */
  var dimmed = true

  /**
   * The size index that the sheet should start to dim the background.
   * This is ignored if `dimmed` is set to `false`.
   */
  var dimmedIndex = 0

  /**
   * The maximum window height
   */
  var maxScreenHeight = 0

  var contentHeight = 0
    set(value) {
      val oldValue = field
      field = value

      // If height changed and using auto size, reconfigure
      if (oldValue != value && sizes.size == 1 && sizes[0] == "auto" && isShowing) {
        // Force expanded state to ensure proper height

        behavior.apply {
          setPeekHeight(value + footerHeight, true)
          state = BottomSheetBehavior.STATE_COLLAPSED
        }
      }
    }
  var footerHeight = 0
  var maxSheetHeight: Int? = null

  var edgeToEdge: Boolean = false
    set(value) {
      field = value
      maxScreenHeight = Utils.screenHeight(reactContext, value)
    }

  var dismissible: Boolean = true
    set(value) {
      field = value
      setCanceledOnTouchOutside(value)
      setCancelable(value)

      behavior.isHideable = value
    }

  var cornerRadius: Float = 0f
  var backgroundColor: Int = Color.WHITE

  // 1st child is the content view
  val contentView: ViewGroup?
    get() = containerView?.getChildAt(0) as? ViewGroup

  // 2nd child is the footer view
  val footerView: ViewGroup?
    get() = containerView?.getChildAt(1) as? ViewGroup

  var sizes: Array<Any> = arrayOf("medium", "large")

  init {
    setContentView(rootSheetView)

    sheetContainerView?.setBackgroundColor(backgroundColor)
    sheetContainerView?.clipToOutline = true

    // Setup window params to adjust layout based on Keyboard state
    window?.apply {
      // Store current windowAnimation value to toggle later
      windowAnimation = attributes.windowAnimations
    }

    // Update the usable sheet height
    maxScreenHeight = Utils.screenHeight(reactContext, edgeToEdge)
  }

  override fun getEdgeToEdgeEnabled(): Boolean = edgeToEdge || super.getEdgeToEdgeEnabled()

  override fun onStart() {
    super.onStart()

    if (edgeToEdge) {
      window?.apply {
        setFlags(
          WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
          WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
        )

        decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
      }
    }
  }

  /**
   * Setup background color and corner radius.
   */
  fun setupBackground() {
    val outerRadii = floatArrayOf(
      cornerRadius,
      cornerRadius,
      cornerRadius,
      cornerRadius,
      0f,
      0f,
      0f,
      0f
    )

    val background = ShapeDrawable(RoundRectShape(outerRadii, null, null))

    // Use current background color
    background.paint.color = backgroundColor
    sheetContainerView?.background = background
  }

  /**
   * Setup dimmed sheet.
   * `dimmedIndex` will further customize the dimming behavior.
   */
  fun setupDimmedBackground(sizeIndex: Int) {
    window?.apply {
      val view = findViewById<View>(com.google.android.material.R.id.touch_outside)

      if (dimmed && sizeIndex >= dimmedIndex) {
        // Remove touch listener
        view.setOnTouchListener(null)

        // Add the dimmed background
        setFlags(
          WindowManager.LayoutParams.FLAG_DIM_BEHIND,
          WindowManager.LayoutParams.FLAG_DIM_BEHIND
        )

        setCanceledOnTouchOutside(dismissible)
      } else {
        // Override the background touch and pass it to the components outside
        view.setOnTouchListener { v, event ->
          event.setLocation(event.rawX - v.x, event.rawY - v.y)
          reactContext.currentActivity?.dispatchTouchEvent(event)
          false
        }

        // Remove the dimmed background
        clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)

        setCanceledOnTouchOutside(false)
      }
    }
  }

  fun resetAnimation() {
    window?.apply {
      setWindowAnimations(windowAnimation)
    }
  }

  /**
   * Present the sheet.
   */
  fun present(sizeIndex: Int, animated: Boolean = true) {
    setupDimmedBackground(sizeIndex)
    if (isShowing) {
      setStateForSizeIndex(sizeIndex)
    } else {
      configure()
      setStateForSizeIndex(sizeIndex)

      if (!animated) {
        // Disable animation
        window?.setWindowAnimations(0)
      }

      show()
    }
  }

  fun positionFooter() {
    footerView?.let { footer ->
      sheetContainerView?.let { container ->
        footer.y = (maxScreenHeight - container.top - footerHeight).toFloat()
      }
    }
  }

  /**
   * Set the state based for the given size index.
   */
  private fun setStateForSizeIndex(index: Int) {
    behavior.state = getStateForSizeIndex(index)
  }

  /**
   * Get the height value based on the size config value.
   */
  private fun getSizeHeight(size: Any): Int {
    val height: Int =
      when (size) {
        is Double -> Utils.toPixel(size).toInt()

        is Int -> Utils.toPixel(size.toDouble()).toInt()

        is String -> {
          when (size) {
            "auto" -> contentHeight + footerHeight

            "large" -> maxScreenHeight

            "medium" -> (maxScreenHeight * 0.50).toInt()

            "small" -> (maxScreenHeight * 0.25).toInt()

            else -> {
              if (size.endsWith('%')) {
                val percent = size.trim('%').toDoubleOrNull()
                if (percent == null) {
                  0
                } else {
                  ((percent / 100) * maxScreenHeight).toInt()
                }
              } else {
                val fixedHeight = size.toDoubleOrNull()
                if (fixedHeight == null) {
                  0
                } else {
                  Utils.toPixel(fixedHeight).toInt()
                }
              }
            }
          }
        }

        else -> (maxScreenHeight * 0.5).toInt()
      }

    return maxSheetHeight?.let { minOf(height, it, maxScreenHeight) } ?: minOf(height, maxScreenHeight)
  }

  /**
   * Determines the state based from the given size index.
   */
  private fun getStateForSizeIndex(index: Int) =
    when (sizes.size) {
      1 -> BottomSheetBehavior.STATE_COLLAPSED

      2 -> {
        when (index) {
          0 -> BottomSheetBehavior.STATE_COLLAPSED
          1 -> BottomSheetBehavior.STATE_EXPANDED
          else -> BottomSheetBehavior.STATE_HIDDEN
        }
      }

      3 -> {
        when (index) {
          0 -> BottomSheetBehavior.STATE_COLLAPSED
          1 -> BottomSheetBehavior.STATE_HALF_EXPANDED
          2 -> BottomSheetBehavior.STATE_EXPANDED
          else -> BottomSheetBehavior.STATE_HIDDEN
        }
      }

      else -> BottomSheetBehavior.STATE_HIDDEN
    }

  /**
   * Handle keyboard state changes and adjust maxScreenHeight (sheet max height) accordingly.
   * Also update footer's Y position.
   */
  fun registerKeyboardManager() {
    keyboardManager.registerKeyboardListener(object : KeyboardManager.OnKeyboardChangeListener {
      override fun onKeyboardStateChange(isVisible: Boolean, visibleHeight: Int?) {
        maxScreenHeight = when (isVisible) {
          true -> visibleHeight ?: 0
          else -> Utils.screenHeight(reactContext, edgeToEdge)
        }

        positionFooter()
      }
    })
  }

  fun setOnSizeChangeListener(listener: (w: Int, h: Int) -> Unit) {
    rootSheetView.sizeChangeListener = listener
  }

  /**
   * Remove keyboard listener.
   */
  fun unregisterKeyboardManager() {
    keyboardManager.unregisterKeyboardListener()
  }

  /**
   * Configure the sheet based from the size preference.
   */
  fun configure() {
    // Configure sheet sizes
    behavior.apply {
      skipCollapsed = false
      isFitToContents = true

      // m3 max width 640dp
      maxWidth = Utils.toPixel(640.0).toInt()

      when (sizes.size) {
        1 -> {
          setPeekHeight(getSizeHeight(sizes[0]), true)
        }

        2 -> {
          setPeekHeight(getSizeHeight(sizes[0]), isShowing)
          maxHeight = getSizeHeight(sizes[1])
        }

        3 -> {
          // Enables half expanded
          isFitToContents = false

          setPeekHeight(getSizeHeight(sizes[0]), isShowing)

          halfExpandedRatio = minOf(getSizeHeight(sizes[1]).toFloat() / maxScreenHeight.toFloat(), 1.0f)
          maxHeight = getSizeHeight(sizes[2])
        }
      }
    }
  }

  /**
   * Get the SizeInfo data by state.
   */
  fun getSizeInfoForState(state: Int): SizeInfo? =
    when (sizes.size) {
      1 -> {
        when (state) {
          BottomSheetBehavior.STATE_COLLAPSED -> SizeInfo(0, Utils.toDIP(behavior.maxHeight.toFloat()))
          else -> null
        }
      }

      2 -> {
        when (state) {
          BottomSheetBehavior.STATE_COLLAPSED -> SizeInfo(0, Utils.toDIP(behavior.peekHeight.toFloat()))
          BottomSheetBehavior.STATE_EXPANDED -> SizeInfo(1, Utils.toDIP(behavior.maxHeight.toFloat()))
          else -> null
        }
      }

      3 -> {
        when (state) {
          BottomSheetBehavior.STATE_COLLAPSED -> SizeInfo(0, Utils.toDIP(behavior.peekHeight.toFloat()))

          BottomSheetBehavior.STATE_HALF_EXPANDED -> {
            val height = behavior.halfExpandedRatio * maxScreenHeight
            SizeInfo(1, Utils.toDIP(height))
          }

          BottomSheetBehavior.STATE_EXPANDED -> SizeInfo(2, Utils.toDIP(behavior.maxHeight.toFloat()))

          else -> null
        }
      }

      else -> null
    }

  /**
   * Get SizeInfo data for given size index.
   */
  fun getSizeInfoForIndex(index: Int) = getSizeInfoForState(getStateForSizeIndex(index)) ?: SizeInfo(0, 0f)

  companion object {
    const val TAG = "TrueSheetView"
  }
}

@lodev09
Copy link
Owner

lodev09 commented Jul 2, 2025

@lovegaoshi yeah I think I tried something similar before but ended up with some blockers, I forgot. Can you submit separate PR? I'll try it out

@adelbeke
Copy link
Author

adelbeke commented Jul 2, 2025

Should I close this one in favour of #193?

@lodev09
Copy link
Owner

lodev09 commented Jul 2, 2025

Closing. Animating during resize seems to be more consistent with IOS. Thanks @adelbeke @lovegaoshi

@lodev09 lodev09 closed this Jul 2, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants