Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,12 @@ import { KeyflowAvoidingView, useKeyflow } from 'react-native-keyflow';
import type { KeyflowThemeOverrides } from 'react-native-keyflow';

const keyflowTheme = {
keyboard: { background: '#F4F0FF' },
keyboard: {
background: '#F4F0FF',
cornerRadius: 28,
borderColor: '#C77DFF',
borderWidth: 2,
},
keys: { background: '#342B62', color: '#FFFFFF' },
specialKeys: { background: '#E05B8D', color: '#FFFFFF' },
} satisfies KeyflowThemeOverrides;
Expand Down Expand Up @@ -140,6 +145,9 @@ import type { KeyflowThemeOverrides } from 'react-native-keyflow';
export const keyflowTheme = {
keyboard: {
background: '#F4F0FF',
cornerRadius: 28,
borderColor: '#C77DFF',
borderWidth: 2,
material: { type: 'raised', depth: 4, shadowColor: '#241D46' },
},
font: { size: 20, weight: 'medium' },
Expand Down Expand Up @@ -176,7 +184,7 @@ const { keyflowInputProps } = useKeyflow({
| `selection` | Focused accent appearance; also used by Android’s active Caps key |
| `toolbar` | Android clipboard and dismiss controls |

Sections support colors, pressed colors, borders, corner radius, fonts, and icon sizing where applicable. `color` supplies the icon and pressed foreground unless explicitly overridden. If you set `iconColor`, remember to give selected states a contrasting icon color too.
Sections support colors, pressed colors, borders, corner radius, fonts, and icon sizing where applicable. The outer panel uses `keyboard.cornerRadius`, `keyboard.borderColor`, and `keyboard.borderWidth`; its border follows the same rounded top edge on iOS and Android. `color` supplies the icon and pressed foreground unless explicitly overridden. If you set `iconColor`, remember to give selected states a contrasting icon color too.

Set the custom return key to app-owned text or a portable native icon:

Expand Down
11 changes: 10 additions & 1 deletion android/src/main/java/com/keyflow/KeyflowKeyboardView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,16 @@ internal class KeyflowKeyboardView(
}

private fun styleKeys() {
background = GradientDrawable().apply { setColor(theme.background) }
background =
GradientDrawable().apply {
setColor(theme.background)
val radius = theme.keyboardRadius * resources.displayMetrics.density
cornerRadii = floatArrayOf(radius, radius, radius, radius, 0f, 0f, 0f, 0f)
setStroke(
(theme.keyboardBorderWidth * resources.displayMetrics.density).toInt(),
theme.keyboardBorderColor,
)
}
val content = theme.json.optJSONObject("returnKeyContent")
keys.forEach {
if (it.action == "submit") {
Expand Down
9 changes: 9 additions & 0 deletions android/src/main/java/com/keyflow/KeyflowTheme.kt
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ internal data class KeyflowTheme(val json: JSONObject = JSONObject()) {
val radius
get() = bounded("keyCornerRadius", 6f, 0f, 24f)

val keyboardRadius
get() = bounded("keyboardCornerRadius", 28f, 0f, 48f)

val keyboardBorderWidth
get() = bounded("keyboardBorderWidth", 0f, 0f, 3f)

val keyboardBorderColor
get() = color("keyboardBorderColor", "#00000000")

val depth
get() = bounded("keyDepth", 4f, 0f, 6f)

Expand Down
24 changes: 14 additions & 10 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,16 +196,20 @@ Available bases: `lightKeyflowTheme`, `darkKeyflowTheme`, `androidKeyflowTheme`,

Sections are `keys`, `specialKeys`, `deleteKey`, `returnKey`, `preview`, `selection`, and `toolbar`. Each supports these fields where applicable:

| Fields | Values |
| ------------------------------------------------------ | --------------------------------------------- |
| `background`, `color`, `iconColor`, `placeholderColor` | `#RRGGBB` or `#RRGGBBAA` |
| `pressedBackground`, `pressedColor`, `borderColor` | Same color format, alpha last |
| `borderWidth` | 0–3 logical pixels |
| `cornerRadius` | 0–24 logical pixels, bounded by the face |
| `fontFamily` | Registered font name, system alias, or `null` |
| `fontSize` | 10–32 logical pixels |
| `fontWeight` | `regular`, `medium`, `bold` |
| `iconSize` | 12–28 logical pixels |
| Fields | Values |
| ------------------------------------------------------ | ----------------------------- |
| `background`, `color`, `iconColor`, `placeholderColor` | `#RRGGBB` or `#RRGGBBAA` |
| `pressedBackground`, `pressedColor`, `borderColor` | Same color format, alpha last |
| `borderWidth` | 0–3 logical pixels |

The `keyboard` section also accepts `cornerRadius` (0–48), `borderColor`, and
`borderWidth` (0–3) for the outer keyboard panel. The border is drawn as one
continuous path across the top edge and around both top corners.
| `cornerRadius` | 0–24 logical pixels, bounded by the face |
| `fontFamily` | Registered font name, system alias, or `null` |
| `fontSize` | 10–32 logical pixels |
| `fontWeight` | `regular`, `medium`, `bold` |
| `iconSize` | 12–28 logical pixels |

Global typography uses `font: { family, size, weight }`, with size 12–32. Sections inherit global values. Partial section updates preserve their other settings. `color` supplies icon/pressed foregrounds unless explicitly overridden; `fontFamily: null` restores the system font for that section.

Expand Down
3 changes: 3 additions & 0 deletions example/src/themes/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { createKeyflowTheme } from 'react-native-keyflow';
export const studioTheme = createKeyflowTheme({
keyboard: {
material: { type: 'raised', depth: 4, shadowColor: '#241D46' },
cornerRadius: 28,
borderColor: '#C77DFF',
borderWidth: 2,
},
background: '#F4F0FF',
keyBackground: '#342B62',
Expand Down
12 changes: 7 additions & 5 deletions ios/KeyflowInputView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,12 @@ final class KeyflowInputView: ExpoView {

func updateInputContext() { keyboard.updateContext(beforeCursor()) }
private let keyboard = KeyflowKeyboardView()
// Keep a nonzero transparent input surface. A zero-height input view makes
// UIKit report keyboardDidHide on subsequent accessory presentations.
private let emptyInput = UIView(frame: CGRect(x: 0, y: 0, width: 390, height: 1))
// UIKit requires a nonzero input surface to present the system keyboard on a
// later handoff. Keep it below one rendered pixel so transparent accessories
// do not leave a visible host row at the bottom of the screen.
private static let emptyInputHeight: CGFloat = 0.01
private let emptyInput = UIView(
frame: CGRect(x: 0, y: 0, width: 390, height: KeyflowInputView.emptyInputHeight))
private var languagesJSON = ""
private var languages: [KeyflowLanguage] = [.english]
private var language = KeyflowLanguage.english
Expand Down Expand Up @@ -219,7 +222,7 @@ final class KeyflowInputView: ExpoView {
self, selector: #selector(refreshLanguages),
name: UIApplication.willEnterForegroundNotification, object: nil)
emptyInput.backgroundColor = .clear
emptyInput.heightAnchor.constraint(equalToConstant: 1).isActive = true
emptyInput.heightAnchor.constraint(equalToConstant: Self.emptyInputHeight).isActive = true
customInputSurface = keyboard
customKeyboard = nil
synchronizeInputSurface()
Expand Down Expand Up @@ -418,7 +421,6 @@ final class KeyflowInputView: ExpoView {
customInputSurface = surface
customKeyboard = accessory ? keyboard : nil
synchronizeInputSurface()
keyboard.setInputSurfaceHeight(accessory ? 1 : 0)
if attachedEditor?.isFirstResponder == true { requestInputReload() }
}

Expand Down
63 changes: 30 additions & 33 deletions ios/KeyflowKeyboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,15 @@ final class KeyflowKeyboardView: UIView {
isTablet ? tabletBasis * (landscape ? 0.10252 : 0.07734) : (landscape ? 106.0 / 3.0 : 54)
}
private var topInset: CGFloat {
isPad ? (landscape ? 2 : 17) : (isTablet ? tabletBasis * (landscape ? 0.0132 : 0.0084) : 4)
isPad ? (landscape ? 2 : 17) : (isTablet ? tabletBasis * (landscape ? 0.0132 : 0.0084) : 8)
}
// Leave the native keyboard's compact clearance above iPhone letter rows
// without reserving the full QuickType suggestion band. iPad keeps its
// measured input-assistant height so its rows align with the system keyboard.
private var inputAssistantHeight: CGFloat {
guard !isPad else { return 0 }
return isTablet ? tabletBasis * 0.06595 : 16
}
// The native iPad keyboard reserves its input-assistant band above the key
// rows. Keyflow leaves it visually empty because predictions are unsupported,
// but preserves the band so the keys and app avoidance line up with UIKit.
private var tabletAssistantHeight: CGFloat { isTablet && !isPad ? tabletBasis * 0.06595 : 0 }
func setKeyboardType(_ value: String) {
let type = ["number-pad", "decimal-pad", "phone-pad"].contains(value) ? value : "default"
guard keyboardType != type else { return }
Expand Down Expand Up @@ -82,10 +85,8 @@ final class KeyflowKeyboardView: UIView {
// Preserve the native portrait typing position even without dock controls.
// The dock includes ergonomic clearance above the home-indicator safe area.
private var bottomInset: CGFloat = 73
private var inputSurfaceHeight: CGFloat = 0
private let panelBackground = UIView()
private let panelMask = CAShapeLayer()
private let panelBottom = UIView()
private let panelShape = CAShapeLayer()
private var preferredHeight: CGFloat = panelHeight
private var presentationActive = true
private var heightConstraint: NSLayoutConstraint!
Expand All @@ -99,7 +100,7 @@ final class KeyflowKeyboardView: UIView {
private var capsLocked = false
private var lastShiftTime: TimeInterval = 0
private var rows: [[KeyflowKey]] = []
private var typingTop: CGFloat { tabletAssistantHeight + topInset }
private var typingTop: CGFloat { inputAssistantHeight + topInset }
private var holdWork: DispatchWorkItem?
private var heldTouch: ObjectIdentifier?
private var heldOrigin: CGPoint = .zero
Expand Down Expand Up @@ -146,13 +147,11 @@ final class KeyflowKeyboardView: UIView {
insertSubview(panelBackground, at: 0)
panelBackground.isUserInteractionEnabled = false
panelBackground.isOpaque = false
panelBottom.isOpaque = false
// Unlike a UIView-backed layer, the standalone mask has default Core
// Animation actions. Layout must not start bounds/position/path animations
// that keep an accessory presentation (and XCTest quiescence) active.
panelMask.actions = ["bounds": NSNull(), "position": NSNull(), "path": NSNull()]
panelBackground.layer.mask = panelMask
panelBackground.addSubview(panelBottom)
panelShape.actions = [
"bounds": NSNull(), "position": NSNull(), "path": NSNull(), "fillColor": NSNull(),
"strokeColor": NSNull(), "lineWidth": NSNull(),
]
panelBackground.layer.addSublayer(panelShape)
translatesAutoresizingMaskIntoConstraints = false
heightConstraint = heightAnchor.constraint(equalToConstant: Self.panelHeight)
heightConstraint.isActive = true
Expand Down Expand Up @@ -222,12 +221,6 @@ final class KeyflowKeyboardView: UIView {
]
}

func setInputSurfaceHeight(_ value: CGFloat) {
guard inputSurfaceHeight != value else { return }
inputSurfaceHeight = value
rebuildKeys()
}

func reset() {
cancelTouches()
page = isTablet && keyboardType != "default" ? .numbers : .letters
Expand All @@ -242,17 +235,19 @@ final class KeyflowKeyboardView: UIView {

override func layoutSubviews() {
super.layoutSubviews()
// Clip only the background siblings, never the keys or callouts.
// Round only the panel's top corners. The bottom surface reaches the
// screen edges; the device supplies its own physical corner clipping.
// Draw the fill and border as one path so a rounded panel always has a
// continuous top edge instead of exposing a corner-only host outline.
UIView.performWithoutAnimation {
panelBackground.frame = bounds
panelBottom.frame = panelBackground.bounds
panelMask.frame = panelBackground.bounds
panelMask.path =
panelShape.frame = panelBackground.bounds
let borderWidth = CGFloat(theme.keyboardBorderWidth)
let panelBounds = panelBackground.bounds.insetBy(dx: borderWidth / 2, dy: borderWidth / 2)
panelShape.path =
UIBezierPath(
roundedRect: panelBackground.bounds, byRoundingCorners: [.topLeft, .topRight],
cornerRadii: CGSize(width: 28, height: 28)
roundedRect: panelBounds, byRoundingCorners: [.topLeft, .topRight],
cornerRadii: CGSize(
width: CGFloat(theme.keyboardCornerRadius),
height: CGFloat(theme.keyboardCornerRadius))
).cgPath
}
KeyflowKeyboardLayout(
Expand Down Expand Up @@ -297,8 +292,8 @@ final class KeyflowKeyboardView: UIView {
// the custom pad remains aligned with the system pad in both orientations.
let tabletPadTopReserve: CGFloat = isTablet && keyboardType != "default" ? 5 : 0
let targetHeight =
tabletAssistantHeight + topInset + 4 * rowHeight + bottomInset + tabletPadTopReserve
+ (isPad && !landscape ? 1 : 0) - inputSurfaceHeight
inputAssistantHeight + topInset + 4 * rowHeight + bottomInset + tabletPadTopReserve
+ (isPad && !landscape ? 1 : 0)
let heightChanged = preferredHeight != targetHeight
preferredHeight = targetHeight
heightConstraint.constant = presentationActive ? targetHeight : 0
Expand Down Expand Up @@ -334,7 +329,9 @@ final class KeyflowKeyboardView: UIView {
item.theme = popupTheme
}
backgroundColor = .clear
panelBottom.backgroundColor = UIColor(keyflowHex: theme.background)
panelShape.fillColor = UIColor(keyflowHex: theme.background).cgColor
panelShape.strokeColor = UIColor(keyflowHex: theme.keyboardBorderColor).cgColor
panelShape.lineWidth = CGFloat(theme.keyboardBorderWidth)
for key in rows.flatMap({ $0 }) {
if key.action == .submit {
let fallback = language.base == "fr" ? "retour" : "return"
Expand Down
3 changes: 3 additions & 0 deletions ios/KeyflowTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ struct KeyflowTheme: Decodable, Equatable {
var fontFamily: String? = nil
var fontSize: Double = 22
var keyCornerRadius: Double = 8
var keyboardCornerRadius: Double = 28
var keyboardBorderColor = "#00000000"
var keyboardBorderWidth: Double = 0

func font(
size: CGFloat, weight: UIFont.Weight = .regular, keyboard: Bool = false,
Expand Down
3 changes: 2 additions & 1 deletion scripts/ci/workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,8 @@ test('device sources contain the proven inventory plus glyph and Shift regressio
group === 'iosRendering'
? [
'testPanelCoversBottomCornersWithoutExtraOpacity',
'testPanelMaskLayoutDoesNotStartImplicitAnimations',
'testPhonePanelFillsItsHostAndKeepsNativeTopClearance',
'testPanelRadiusAndBorderUseOneContinuousShape',
'testTrackpadCaretFloatsBetweenCharactersAndRestoresOnRelease',
'testTrackpadSnapsToTheVisuallyNearestCaretInsteadOfTheTrailingHit',
'testTrackpadReleaseCommitsTheVisibleCaretBoundary',
Expand Down
76 changes: 51 additions & 25 deletions scripts/ios-tests/KeyflowRenderingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,57 @@ final class KeyflowRenderingTests: XCTestCase {
XCTAssertTrue(field.inputView === original)
}

func testPhonePanelFillsItsHostAndKeepsNativeTopClearance() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom == .pad, "iPhone panel geometry")
let keyboard = KeyflowKeyboardView()
keyboard.updateViewport(
CGSize(width: 390, height: 844),
insets: UIEdgeInsets(top: 0, left: 0, bottom: 34, right: 0))
keyboard.frame = CGRect(
x: 0, y: 0, width: 390, height: keyboard.intrinsicContentSize.height)
keyboard.layoutIfNeeded()

let firstKey = try XCTUnwrap(
keyboard.subviews.compactMap { $0 as? KeyflowKey }.first { $0.caption == "Q" })
firstKey.layoutIfNeeded()
XCTAssertEqual(firstKey.frame.minY, 24, accuracy: 0.01)
XCTAssertEqual(firstKey.face.frame.minY, 6, accuracy: 0.01)
XCTAssertEqual(keyboard.intrinsicContentSize.height, 313, accuracy: 0.01)

let panel = try XCTUnwrap(
Mirror(reflecting: keyboard).children.first { $0.label == "panelBackground" }?.value
as? UIView)
XCTAssertEqual(panel.frame, keyboard.bounds)
XCTAssertNil(panel.layer.mask, "The panel must not expose a partial rounded outline")
}

func testPanelRadiusAndBorderUseOneContinuousShape() throws {
let keyboard = KeyflowKeyboardView()
var theme = KeyflowTheme()
theme.keyboardCornerRadius = 28
theme.keyboardBorderColor = "#C77DFF"
theme.keyboardBorderWidth = 2
keyboard.theme = theme
keyboard.updateViewport(
CGSize(width: 390, height: 844),
insets: UIEdgeInsets(top: 0, left: 0, bottom: 34, right: 0))
keyboard.frame = CGRect(
x: 0, y: 0, width: 390, height: keyboard.intrinsicContentSize.height)
keyboard.layoutIfNeeded()

let shape = try XCTUnwrap(
Mirror(reflecting: keyboard).children.first { $0.label == "panelShape" }?.value
as? CAShapeLayer)
XCTAssertEqual(shape.lineWidth, 2)
XCTAssertEqual(shape.strokeColor, UIColor(keyflowHex: "#C77DFF").cgColor)
XCTAssertEqual(shape.fillColor, UIColor(keyflowHex: theme.background).cgColor)
let path = try XCTUnwrap(shape.path)
XCTAssertEqual(path.boundingBox.minX, 1, accuracy: 0.01)
XCTAssertEqual(path.boundingBox.maxX, 389, accuracy: 0.01)
XCTAssertTrue(path.contains(CGPoint(x: 195, y: 1)), "The top border must span the panel")
XCTAssertFalse(path.contains(CGPoint(x: 1, y: 1)), "The configured top corner must remain rounded")
}

func testAccentPopupOmitsBordersAndUsesKeyRadius() throws {
let style = KeyflowSectionStyle(
background: "#FFFF00", color: "#FFFFFF", placeholderColor: "#FFFFFF",
Expand Down Expand Up @@ -119,31 +170,6 @@ final class KeyflowRenderingTests: XCTestCase {
func testPhonePadRaisedLargeSquareFits() { assertFits("phone-pad", "raised", 32.0, 0.0) }
func testPhonePadRaisedLargeRoundedFits() { assertFits("phone-pad", "raised", 32.0, 24.0) }

func testPanelMaskLayoutDoesNotStartImplicitAnimations() throws {
let keyboard = KeyflowKeyboardView()
let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
let window = scene.map { UIWindow(windowScene: $0) } ?? UIWindow(frame: UIScreen.main.bounds)
let controller = UIViewController()
window.rootViewController = controller
window.makeKeyAndVisible()
defer { window.isHidden = true }
controller.view.addSubview(keyboard)
keyboard.frame = CGRect(x: 0, y: 100, width: 390, height: keyboard.intrinsicContentSize.height)
keyboard.layoutIfNeeded()
let mask = try XCTUnwrap(keyboard.subviews.compactMap { $0.layer.mask }.first)
CATransaction.flush()
// UIKit lays the accessory out during an animated keyboard presentation.
// Static clipping geometry must not add another animation on every layout.
CATransaction.begin()
CATransaction.setAnimationDuration(10)
keyboard.frame.size.width += 1
keyboard.setNeedsLayout()
keyboard.layoutIfNeeded()
CATransaction.commit()
CATransaction.flush()
XCTAssertEqual(mask.animationKeys() ?? [], [], "Static mask animations can keep XCTest waiting for the app to idle")
}

func testPanelCoversBottomCornersWithoutExtraOpacity() throws {
for color in ["#163B50", "#163B5080"] {
let keyboard = KeyflowKeyboardView()
Expand Down
10 changes: 10 additions & 0 deletions scripts/visual/transparency.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ const colors = (png, metrics) => {
width: metrics.width * 0.1,
height: 2,
}),
bottom: sampleColor(png, metrics, {
x: metrics.width * 0.4,
y: metrics.height - 2,
width: metrics.width * 0.2,
height: 1,
}),
};
};
const delta = (a, b) =>
Expand Down Expand Up @@ -185,6 +191,10 @@ try {
delta(samples.clear.panel, samples['half-keys'].panel) < 1,
'Key alpha leaked into the transparent panel',
);
assert(
Math.min(...samples.clear.bottom) < 220,
'Transparent iOS input host left a white seam below the keyboard',
);
assert(delta(samples.clear.space, samples['half-keys'].space) > 40);
assert(
delta(samples['half-keys'].space, samples['background-only'].space) > 40,
Expand Down
Loading
Loading