Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 55
ADFA-1428 - ADFA-1439 | Refactor constraint handling and centralize position restoration#545
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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
069fd0d5a091ae13adc613754b9531274b1e8b45122df1d2a61c678adff47a28acec249a7a95792c400afced360ec3f84a454ee63ebf83127adf8374cf90e7ca55551d0982d650e90c2c93cffe697387105776a4c0501ee1966f68da2c65630ba165File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -131,8 +131,8 @@ fun AlertDialog.onLongPress(listener: (View) -> Boolean) { | ||
| @SuppressLint("ClickableViewAccessibility") | ||
| fun View.handleLongClicksAndDrag( | ||
| onDrop: ((View, Float, Float) -> Unit)? = null, | ||
| onLongPress: (View) -> Unit, | ||
| onDrag: (View) -> Unit | ||
| ) { | ||
| var viewInitialX = 0f | ||
| var viewInitialY = 0f | ||
| @@ -198,10 +198,9 @@ fun View.handleLongClicksAndDrag( | ||
| longPressFired = false | ||
| if (wasDraggingDuringGesture) { | ||
| onDrag(view) | ||
| onDrop?.invoke(view, view.x, view.y) | ||
| return@setOnTouchListener true | ||
jatezzz marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. jatezzz marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| if (wasLongPressFiredDuringGesture) { | ||
| return@setOnTouchListener true | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -15,6 +15,7 @@ import android.widget.LinearLayout | ||
| import android.widget.Spinner | ||
| import android.widget.Toast | ||
| import androidx.appcompat.widget.TooltipCompat | ||
| import androidx.constraintlayout.widget.ConstraintLayout | ||
| import androidx.core.view.isEmpty | ||
| import androidx.recyclerview.widget.LinearLayoutManager | ||
| import androidx.recyclerview.widget.RecyclerView | ||
| @@ -52,6 +53,7 @@ import org.appdevforall.codeonthego.layouteditor.managers.PreferencesManager | ||
| import org.appdevforall.codeonthego.layouteditor.managers.UndoRedoManager | ||
| import org.appdevforall.codeonthego.layouteditor.tools.XmlLayoutGenerator | ||
| import org.appdevforall.codeonthego.layouteditor.tools.XmlLayoutParser | ||
| import org.appdevforall.codeonthego.layouteditor.utils.restorePositionsAfterLoad | ||
| import org.appdevforall.codeonthego.layouteditor.utils.ArgumentUtil.parseType | ||
| import org.appdevforall.codeonthego.layouteditor.utils.Constants | ||
| import org.appdevforall.codeonthego.layouteditor.utils.FileUtil | ||
| @@ -86,6 +88,8 @@ class DesignEditor : LinearLayout { | ||
| private var isModified = false | ||
| private lateinit var preferencesManager: PreferencesManager | ||
| private var parser: XmlLayoutParser? = null | ||
| private val attrTranslationX = "android:translationX" | ||
| private val attrTranslationY = "android:translationY" | ||
| init { | ||
| initAttributes() | ||
| @@ -217,6 +221,52 @@ class DesignEditor : LinearLayout { | ||
| } | ||
| } | ||
| /** | ||
| * Updates the **stored attributes** in [viewAttributeMap] for a [child] view | ||
| * after it's "dropped" at a new position (x, y) within its [ConstraintLayout] parent. | ||
| * | ||
| * This function performs the following actions: | ||
| * 1. Clamps the raw (x, y) pixel coordinates to ensure the view stays within the | ||
| * parent container's bounds. | ||
| * 2. Converts the clamped pixel coordinates to dp using the screen density. | ||
| * 3. Updates the [viewAttributeMap] for the [child]: | ||
| * - Clears any existing `...constraintBottom_toBottomOf` or `...constraintEnd_toEndOf` attributes. | ||
| * - Sets `app:layout_constraintStart_toStartOf = "parent"` | ||
| * - Sets `app:layout_constraintTop_toTopOf = "parent"` | ||
| * - Sets `app:layout_marginStart = "<x>dp"` | ||
| * - Sets `app:layout_marginTop = "<y>dp"` | ||
| * | ||
| * @param child The view being positioned. | ||
| * @param x The target X coordinate in container pixels. | ||
| * @param y The target Y coordinate in container pixels. | ||
| */ | ||
| private fun positionAtDrop(child: View, x: Float, y: Float) { | ||
| val container = child.parent as? ConstraintLayout ?: return | ||
| val density = container.resources.displayMetrics.density | ||
| val maxX = (container.width - child.width).coerceAtLeast(0).toFloat() | ||
| val maxY = (container.height - child.height).coerceAtLeast(0).toFloat() | ||
| val xPx = x.coerceIn(0f, maxX) | ||
| val yPx = y.coerceIn(0f, maxY) | ||
| val xDp = xPx / density | ||
| val yDp = yPx / density | ||
| viewAttributeMap[child]?.apply { | ||
| if (contains("app:layout_constraintBottom_toBottomOf")) removeValue("app:layout_constraintBottom_toBottomOf") | ||
| if (contains("app:layout_constraintEnd_toEndOf")) removeValue("app:layout_constraintEnd_toEndOf") | ||
| putValue("app:layout_constraintStart_toStartOf", "parent") | ||
| putValue("app:layout_constraintTop_toTopOf", "parent") | ||
| putValue("app:layout_marginStart", "${xDp}dp") | ||
| putValue("app:layout_marginTop", "${yDp}dp") | ||
| } | ||
jatezzz marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| markAsModified() | ||
| } | ||
| private fun setDragListener(group: ViewGroup) { | ||
| group.setOnDragListener( | ||
| OnDragListener { host, event -> | ||
| @@ -256,10 +306,7 @@ class DesignEditor : LinearLayout { | ||
| if (index != newIndex) { | ||
| parent.removeView(shadow) | ||
| try { | ||
| parent.addView(shadow, newIndex) | ||
| } catch (_: IllegalStateException) { | ||
| } | ||
| runCatching { parent.addView(shadow, newIndex) } | ||
| } | ||
| } else { | ||
| if (shadow.parent !== parent) addWidget(shadow, parent, event) | ||
| @@ -280,6 +327,7 @@ class DesignEditor : LinearLayout { | ||
| if (parent is DesignEditor) parent = getChildAt(0) as ViewGroup | ||
| } | ||
| } | ||
| if (draggedView == null) { | ||
| @Suppress("UNCHECKED_CAST") val data: HashMap<String, Any> = | ||
| event.localState as HashMap<String, Any> | ||
| @@ -334,12 +382,17 @@ class DesignEditor : LinearLayout { | ||
| if (data.containsKey(Constants.KEY_DEFAULT_ATTRS)) { | ||
| @Suppress("UNCHECKED_CAST") | ||
| initializer.applyDefaultAttributes( | ||
| newView, | ||
| data[Constants.KEY_DEFAULT_ATTRS] as MutableMap<String, String> | ||
| ) | ||
| val defaults = (data[Constants.KEY_DEFAULT_ATTRS] as MutableMap<String, String>).toMutableMap() | ||
| defaults.remove(attrTranslationX) | ||
| defaults.remove(attrTranslationY) | ||
| initializer.applyDefaultAttributes(newView, defaults) | ||
| } | ||
| positionAtDrop(newView, event.x, event.y) | ||
| val rootLayout = getChildAt(0) | ||
| restorePositionsAfterLoad(rootLayout, viewAttributeMap) | ||
| } else addWidget(draggedView, parent, event) | ||
| updateStructure() | ||
| updateUndoRedoHistory() | ||
| } | ||
| @@ -499,8 +552,13 @@ class DesignEditor : LinearLayout { | ||
| tag = view.javaClass.superclass.name | ||
| ) | ||
| }, | ||
| onDrag = { | ||
| view.startDragAndDrop(null, DragShadowBuilder(view), view, 0) | ||
| onDrop = { child, x, y -> | ||
| positionAtDrop(child, x, y) | ||
| val rootLayout = getChildAt(0) | ||
| restorePositionsAfterLoad(rootLayout, viewAttributeMap) | ||
| updateStructure() | ||
| updateUndoRedoHistory() | ||
| } | ||
jatezzz marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| package org.appdevforall.codeonthego.layouteditor.utils | ||
| import android.view.View | ||
| import androidx.constraintlayout.widget.ConstraintLayout | ||
| import androidx.constraintlayout.widget.ConstraintSet | ||
| import androidx.core.view.doOnLayout | ||
| import org.appdevforall.codeonthego.layouteditor.editor.initializer.AttributeMap | ||
| /** | ||
| * Modifies a [ConstraintSet] to apply basic constraints to a specific view. | ||
| * | ||
| * - Anchors the view to the parent's START and TOP. | ||
| * - Clears existing END and BOTTOM constraints to avoid conflicts. | ||
| * - Sets START and TOP margins in **pixels** using the provided values. | ||
| * | ||
| * @param constraintSet The [ConstraintSet] instance to modify. | ||
| * @param viewId The ID of the target view. | ||
| * @param startPxMargin The START margin in **pixels**. | ||
| * @param topPxMargin The TOP margin in **pixels**. | ||
| */ | ||
| private fun modifyConstraintsForView(constraintSet: ConstraintSet, viewId: Int, startPxMargin: Int, topPxMargin: Int) { | ||
| constraintSet.clear(viewId, ConstraintSet.BOTTOM) | ||
| constraintSet.clear(viewId, ConstraintSet.END) | ||
| constraintSet.connect(viewId, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) | ||
| constraintSet.connect(viewId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP) | ||
| constraintSet.setMargin(viewId, ConstraintSet.START, startPxMargin) | ||
| constraintSet.setMargin(viewId, ConstraintSet.TOP, topPxMargin) | ||
| } | ||
| /** | ||
| * Converts a dimension string (e.g., `"12px"`, `"8dp"`, `"10dip"`, or `"14"`) | ||
| * into pixels using the given [density]. | ||
| * | ||
| * Supported suffixes: | ||
| * - `"px"` → interpreted as raw pixels. | ||
| * - `"dp"` or `"dip"` → multiplied by display density. | ||
| * - No suffix → assumed to be dp and multiplied by density. | ||
| * | ||
| * @receiver The dimension string to convert. | ||
| * @param density The display density for dp-to-px conversion. | ||
| * @return The equivalent pixel value, or `0f` if parsing fails. | ||
| */ | ||
| private fun String.toPx(density: Float): Float { | ||
jatezzz marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| val trimmed = trim() | ||
| return when { | ||
| trimmed.endsWith("px", true) -> trimmed.dropLast(2).toFloatOrNull()?.takeIf { it.isFinite() } ?: 0f | ||
| trimmed.endsWith("dp", true) -> (trimmed.dropLast(2).toFloatOrNull() ?: 0f) * density | ||
| trimmed.endsWith("dip", true) -> (trimmed.dropLast(3).toFloatOrNull() ?: 0f) * density | ||
| else -> (trimmed.toFloatOrNull() ?: 0f) * density // assume dp if no suffix | ||
| } | ||
| } | ||
| /** | ||
| * Helper class to store a set of constraint changes for a single view. | ||
| */ | ||
| private data class ViewConstraintChange( | ||
| val viewId: Int, | ||
| val startMargin: Int, | ||
| val topMargin: Int | ||
| ) | ||
| /** | ||
| * Efficiently restores the positions of draggable views by reapplying constraints | ||
| * after the layout has finished rendering. | ||
| * | ||
| * This function uses a two-pass batching mechanism for efficiency: | ||
| * | ||
| * 1. **Collection Pass:** Iterates [attributeMap], calculates pixel margins, | ||
| * resets [View.translationX]/[View.translationY] to 0, and groups all | ||
| * required changes by their parent [ConstraintLayout]. | ||
| * 2. **Application Pass:** Iterates over each unique parent [ConstraintLayout], | ||
| * clones its state into a *single* [ConstraintSet], applies *all* | ||
| * changes for that group using [modifyConstraintsForView], and then calls | ||
| * [ConstraintSet.applyTo] **once** per container. | ||
| * | ||
| * This ensures only one layout pass is triggered per [ConstraintLayout] container, | ||
| * instead of N passes. | ||
| * | ||
| * @param rootView The root [View] to observe for layout completion. | ||
| * @param attributeMap A [Map] of [View]s to their corresponding [AttributeMap]. | ||
| */ | ||
| fun restorePositionsAfterLoad(rootView: View, attributeMap: Map<View, AttributeMap>) { | ||
| rootView.doOnLayout { container -> | ||
| val density = container.resources.displayMetrics.density | ||
| val changesByContainer = mutableMapOf<ConstraintLayout, MutableList<ViewConstraintChange>>() | ||
| // --- 1. COLLECTION PASS --- | ||
| attributeMap.forEach { (view, attrs) -> | ||
| val constraintContainer = view.parent as? ConstraintLayout ?: return@forEach | ||
| val txStr = attrs.getValue("app:layout_marginStart") | ||
| val tyStr = attrs.getValue("app:layout_marginTop") | ||
| if (txStr.isNotEmpty() || tyStr.isNotEmpty()) { | ||
| val maxX = (constraintContainer.width - view.width).coerceAtLeast(0).toFloat() | ||
| val maxY = (constraintContainer.height - view.height).coerceAtLeast(0).toFloat() | ||
| val txPx = txStr.toPx(density).coerceIn(0f, maxX) | ||
| val tyPx = tyStr.toPx(density).coerceIn(0f, maxY) | ||
| view.translationX = 0f | ||
| view.translationY = 0f | ||
| val changesList = changesByContainer.getOrPut(constraintContainer) { mutableListOf() } | ||
| changesList.add(ViewConstraintChange( | ||
| viewId = view.id, | ||
| startMargin = txPx.toInt(), | ||
| topMargin = tyPx.toInt() | ||
| )) | ||
| } | ||
| } | ||
| // --- 2. APPLICATION PASS --- | ||
| changesByContainer.forEach { (container, changeList) -> | ||
| val constraintSet = ConstraintSet() | ||
| constraintSet.clone(container) | ||
| changeList.forEach { change -> | ||
| modifyConstraintsForView( | ||
| constraintSet, | ||
| change.viewId, | ||
| change.startMargin, | ||
| change.topMargin | ||
| ) | ||
| } | ||
| constraintSet.applyTo(container) | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.