Skip to content

Commit d9c30b0

Browse files
authored
Port Swift implementation of basic widgets to Kotlin (#2581)
1 parent f0b3c20 commit d9c30b0

File tree

16 files changed

+322
-285
lines changed

16 files changed

+322
-285
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Klib ABI Dump
2+
// Targets: [iosArm64, iosSimulatorArm64, iosX64]
3+
// Rendering settings:
4+
// - Signature version: 2
5+
// - Show manifest properties: true
6+
// - Show declarations: true
7+
8+
// Library unique name: <app.cash.redwood:redwood-basic-uiview>
9+
final class app.cash.redwood.basic.uiview/UIViewRedwoodBasicWidgetFactory : app.cash.redwood.basic.widget/RedwoodBasicWidgetFactory<platform.UIKit/UIView> { // app.cash.redwood.basic.uiview/UIViewRedwoodBasicWidgetFactory|null[0]
10+
constructor <init>() // app.cash.redwood.basic.uiview/UIViewRedwoodBasicWidgetFactory.<init>|<init>(){}[0]
11+
12+
final fun Image(): app.cash.redwood.basic.widget/Image<platform.UIKit/UIView> // app.cash.redwood.basic.uiview/UIViewRedwoodBasicWidgetFactory.Image|Image(){}[0]
13+
final fun Reuse(platform.UIKit/UIView, app.cash.redwood.basic.modifier/Reuse) // app.cash.redwood.basic.uiview/UIViewRedwoodBasicWidgetFactory.Reuse|Reuse(platform.UIKit.UIView;app.cash.redwood.basic.modifier.Reuse){}[0]
14+
final fun Text(): app.cash.redwood.basic.widget/Text<platform.UIKit/UIView> // app.cash.redwood.basic.uiview/UIViewRedwoodBasicWidgetFactory.Text|Text(){}[0]
15+
final fun TextInput(): app.cash.redwood.basic.widget/TextInput<platform.UIKit/UIView> // app.cash.redwood.basic.uiview/UIViewRedwoodBasicWidgetFactory.TextInput|TextInput(){}[0]
16+
}

redwood-basic-uiview/build.gradle

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import static app.cash.redwood.buildsupport.TargetGroup.ToolkitIos
2+
3+
redwoodBuild {
4+
targets(ToolkitIos)
5+
publishing()
6+
}
7+
8+
kotlin {
9+
sourceSets {
10+
commonMain {
11+
dependencies {
12+
api projects.redwoodBasicWidget
13+
}
14+
}
15+
}
16+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright (C) 2025 Square, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package app.cash.redwood.basic.uiview
17+
18+
import kotlinx.coroutines.Dispatchers
19+
import kotlinx.coroutines.IO
20+
import kotlinx.coroutines.MainScope
21+
import kotlinx.coroutines.launch
22+
import kotlinx.coroutines.withContext
23+
import platform.Foundation.NSData
24+
import platform.Foundation.NSURL
25+
import platform.Foundation.create
26+
import platform.UIKit.UIImage
27+
28+
internal class RemoteImageLoader {
29+
private val scope = MainScope()
30+
private val cache = mutableMapOf<NSURL, UIImage>()
31+
32+
fun loadImage(url: NSURL, callback: (NSURL, UIImage) -> Unit) {
33+
cache[url]?.let {
34+
callback(url, it)
35+
return
36+
}
37+
38+
// This is not a good image loader, but it's a good-enough image loader.
39+
scope.launch {
40+
val image = withContext(Dispatchers.IO) {
41+
NSData.create(url)?.let { data ->
42+
UIImage.imageWithData(data)
43+
}
44+
}
45+
if (image != null) {
46+
cache[url] = image
47+
callback(url, image)
48+
}
49+
}
50+
}
51+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* Copyright (C) 2025 Square, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package app.cash.redwood.basic.uiview
17+
18+
import app.cash.redwood.Modifier
19+
import app.cash.redwood.basic.widget.Image
20+
import kotlinx.cinterop.ObjCAction
21+
import platform.Foundation.NSSelectorFromString
22+
import platform.Foundation.NSURL
23+
import platform.UIKit.UIImageView
24+
import platform.UIKit.UILayoutConstraintAxisHorizontal
25+
import platform.UIKit.UILayoutPriorityRequired
26+
import platform.UIKit.UITapGestureRecognizer
27+
import platform.UIKit.UIView
28+
import platform.UIKit.UIViewContentMode.UIViewContentModeScaleAspectFit
29+
import platform.darwin.NSObject
30+
31+
internal class UIViewImage(
32+
private val imageLoader: RemoteImageLoader,
33+
) : Image<UIView> {
34+
override val value = UIImageView().apply {
35+
contentMode = UIViewContentModeScaleAspectFit
36+
setContentHuggingPriority(UILayoutPriorityRequired, UILayoutConstraintAxisHorizontal)
37+
setUserInteractionEnabled(true)
38+
}
39+
40+
private var lastUrl: NSURL? = null
41+
private var onClick: (() -> Unit)? = null
42+
private var onClickGestureRecognizer: UITapGestureRecognizer? = null
43+
44+
private val clickedTarget = ClickedTarget(this)
45+
private val clickedPointer = NSSelectorFromString(ClickedTarget::clicked.name)
46+
47+
override fun url(url: String) {
48+
value.image = null
49+
50+
val url = NSURL.URLWithString(url) ?: return
51+
lastUrl = url
52+
53+
imageLoader.loadImage(url) { url, image ->
54+
if (url == lastUrl) {
55+
value.image = image
56+
}
57+
}
58+
}
59+
60+
override fun onClick(onClick: (() -> Unit)?) {
61+
if (this.onClick == null) {
62+
if (onClick != null) {
63+
onClickGestureRecognizer = UITapGestureRecognizer().apply {
64+
addTarget(clickedTarget, clickedPointer)
65+
value.addGestureRecognizer(this)
66+
}
67+
}
68+
} else if (onClick == null) {
69+
value.removeGestureRecognizer(onClickGestureRecognizer!!)
70+
onClickGestureRecognizer = null
71+
}
72+
this.onClick = onClick
73+
}
74+
75+
private fun clicked() {
76+
onClick?.invoke()
77+
}
78+
79+
/**
80+
* Selectors can only target subtypes of [NSObject] which is why this helper exists.
81+
* We cannot subclass it on [UIViewImage] directly because it implements a Kotlin
82+
* interface, but we also don't want to leak it into public API. The contained function
83+
* must be public for the selector to work.
84+
*/
85+
private class ClickedTarget(private val target: UIViewImage) : NSObject() {
86+
@ObjCAction
87+
fun clicked() {
88+
target.clicked()
89+
}
90+
}
91+
92+
override var modifier: Modifier = Modifier
93+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright (C) 2025 Square, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package app.cash.redwood.basic.uiview
17+
18+
import app.cash.redwood.basic.modifier.Reuse
19+
import app.cash.redwood.basic.widget.Image
20+
import app.cash.redwood.basic.widget.RedwoodBasicWidgetFactory
21+
import app.cash.redwood.basic.widget.Text
22+
import app.cash.redwood.basic.widget.TextInput
23+
import platform.UIKit.UIView
24+
25+
@ObjCName("UIViewRedwoodBasicWidgetFactory", exact = true)
26+
public class UIViewRedwoodBasicWidgetFactory : RedwoodBasicWidgetFactory<UIView> {
27+
private val imageLoader = RemoteImageLoader()
28+
29+
override fun TextInput(): TextInput<UIView> = UIViewTextInput()
30+
override fun Text(): Text<UIView> = UIViewText()
31+
override fun Image(): Image<UIView> = UIViewImage(imageLoader)
32+
33+
override fun Reuse(value: UIView, modifier: Reuse) {}
34+
}

samples/emoji-search/ios-uikit/EmojiSearchApp/TextBinding.swift renamed to redwood-basic-uiview/src/commonMain/kotlin/app/cash/redwood/basic/uiview/UIViewText.kt

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2022 Square, Inc.
2+
* Copyright (C) 2025 Square, Inc.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -13,25 +13,20 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16+
package app.cash.redwood.basic.uiview
1617

17-
import Foundation
18-
import EmojiSearchKt
19-
import UIKit
18+
import app.cash.redwood.Modifier
19+
import app.cash.redwood.basic.widget.Text
20+
import platform.UIKit.UILabel
21+
import platform.UIKit.UIView
2022

21-
class TextBinding: Text {
22-
private let root: UILabel = {
23-
let view = UILabel()
24-
return view
25-
}()
23+
internal class UIViewText : Text<UIView> {
24+
override val value = UILabel()
2625

27-
var modifier: Modifier = ExposedKt.modifier()
28-
var value: Any { root }
26+
override fun text(text: String) {
27+
value.text = text
28+
value.sizeToFit()
29+
}
2930

30-
func text(text: String) {
31-
root.text = text
32-
33-
// This very simple integration wraps the size of whatever text is entered. Calling
34-
// this function will update the bounds and trigger relayout in the parent.
35-
root.sizeToFit()
36-
}
31+
override var modifier: Modifier = Modifier
3732
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Copyright (C) 2025 Square, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package app.cash.redwood.basic.uiview
17+
18+
import app.cash.redwood.Modifier
19+
import app.cash.redwood.basic.api.TextFieldState
20+
import app.cash.redwood.basic.widget.TextInput
21+
import kotlinx.cinterop.ObjCAction
22+
import platform.Foundation.NSSelectorFromString
23+
import platform.UIKit.UIControlEventEditingChanged
24+
import platform.UIKit.UITextAutocapitalizationType.UITextAutocapitalizationTypeNone
25+
import platform.UIKit.UITextBorderStyle.UITextBorderStyleRoundedRect
26+
import platform.UIKit.UITextField
27+
import platform.UIKit.UIView
28+
import platform.darwin.NSObject
29+
30+
internal class UIViewTextInput : TextInput<UIView> {
31+
override val value = UITextField().apply {
32+
borderStyle = UITextBorderStyleRoundedRect
33+
autocapitalizationType = UITextAutocapitalizationTypeNone
34+
}
35+
36+
private var updating = false
37+
private var state = TextFieldState()
38+
private var onChange: ((TextFieldState) -> Unit)? = null
39+
40+
private val stateChangedTarget = StateChangedTarget(this)
41+
private val stateChangedPointer = NSSelectorFromString(StateChangedTarget::stateChanged.name)
42+
43+
override fun state(state: TextFieldState) {
44+
if (state.userEditCount > this.state.userEditCount) {
45+
return
46+
}
47+
require(!updating)
48+
updating = true
49+
this.state = state
50+
value.text = state.text
51+
updating = false
52+
}
53+
54+
override fun hint(hint: String) {
55+
value.placeholder = hint
56+
}
57+
58+
override fun onChange(onChange: ((TextFieldState) -> Unit)?) {
59+
if (this.onChange == null) {
60+
if (onChange != null) {
61+
value.addTarget(stateChangedTarget, stateChangedPointer, UIControlEventEditingChanged)
62+
}
63+
} else if (onChange == null) {
64+
value.removeTarget(stateChangedTarget, stateChangedPointer, UIControlEventEditingChanged)
65+
}
66+
this.onChange = onChange
67+
}
68+
69+
private fun stateChanged() {
70+
// Ignore this update if it isn't a user edit.
71+
if (updating) return
72+
73+
val newState = state.userEdit(value.text ?: "", 0, 0)
74+
if (state != newState) {
75+
state = newState
76+
onChange?.invoke(newState)
77+
}
78+
}
79+
80+
/**
81+
* Selectors can only target subtypes of [NSObject] which is why this helper exists.
82+
* We cannot subclass it on [UIViewTextInput] directly because it implements a Kotlin
83+
* interface, but we also don't want to leak it into public API. The contained function
84+
* must be public for the selector to work.
85+
*/
86+
private class StateChangedTarget(private val target: UIViewTextInput) : NSObject() {
87+
@ObjCAction
88+
fun stateChanged() {
89+
target.stateChanged()
90+
}
91+
}
92+
93+
override var modifier: Modifier = Modifier
94+
}

samples/emoji-search/ios-shared/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ kotlin {
1717
implementation projects.samples.emojiSearch.launcher
1818
implementation projects.samples.emojiSearch.presenterTreehouse
1919
implementation projects.samples.emojiSearch.schemaProtocolHost
20+
implementation projects.redwoodBasicUiview
2021
implementation projects.redwoodLayoutUiview
2122
implementation projects.redwoodLazylayoutUiview
2223
}

samples/emoji-search/ios-shared/src/commonMain/kotlin/com/example/redwood/emojisearch/ios/exposed.kt

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package com.example.redwood.emojisearch.ios
1919

2020
import app.cash.redwood.Modifier
2121
import app.cash.redwood.basic.protocol.host.RedwoodBasicProtocolFactory
22+
import app.cash.redwood.basic.uiview.UIViewRedwoodBasicWidgetFactory
2223
import app.cash.redwood.basic.widget.RedwoodBasicWidgetFactory
2324
import app.cash.redwood.basic.widget.RedwoodBasicWidgetSystem
2425
import app.cash.redwood.layout.uiview.UIViewRedwoodLayoutWidgetFactory
@@ -29,16 +30,14 @@ import app.cash.redwood.treehouse.TreehouseUIView
2930
import app.cash.redwood.treehouse.TreehouseView
3031
import app.cash.redwood.treehouse.TreehouseView.WidgetSystem
3132
import app.cash.redwood.treehouse.bindWhenReady
32-
import okio.ByteString
33-
import okio.ByteString.Companion.toByteString
3433
import okio.Closeable
35-
import platform.Foundation.NSData
3634

3735
// Used to export types to Objective-C / Swift.
3836
fun exposedTypes(
3937
emojiSearchLauncher: EmojiSearchLauncher,
4038
protocolFactory: RedwoodBasicProtocolFactory<*>,
4139
treehouseUIView: TreehouseUIView,
40+
uiViewRedwoodBasicWidgetFactory: UIViewRedwoodBasicWidgetFactory,
4241
uiViewRedwoodLayoutWidgetFactory: UIViewRedwoodLayoutWidgetFactory,
4342
uiViewRedwoodLazyLayoutWidgetFactory: UIViewRedwoodLazyLayoutWidgetFactory,
4443
treehouseWidgetSystem: WidgetSystem<*>,
@@ -48,8 +47,6 @@ fun exposedTypes(
4847
throw AssertionError()
4948
}
5049

51-
fun byteStringOf(data: NSData): ByteString = data.toByteString()
52-
5350
fun modifier(): Modifier = Modifier
5451

5552
fun <A : AppService> bindWhenReady(

0 commit comments

Comments
 (0)