活字排版从字库取出一个字,到放进印板的指定格位,中间至少经过两次选择:先确定使用哪枚字模,再确定字模落在哪个格子。移动端把两次选择合成一次点击,看似省事,却很容易造成误落字。尤其在小屏上,手指先浏览字库、再调整版面时,如果选中位置随列表刷新改变,就可能把新字放到错误的行列。

TypeFoundry的第一块印板采用四行六列,共二十四个位置。字库只有“山、海、云、风、月、光”六枚字模,初始排在第一行,后面十八格保留为空。第一行第二列的“海”是当前焦点。这里先解决一个基础约定:空格也是有身份的业务对象,格子身份与字模身份分别维护。这个约定会直接影响后续字模复用、字体级别、分栏适配和版面恢复。

四行六列活字印板与选字定位

从视觉序号切换到稳定格位键

如果将板面保存为一个纯字符串,删除或插入字符以后,后续字符的下标都会移动。反过来,将二十四个位置固定下来,每个格位总有自己的行、列、键,空位通过空字模标识表示,局部改动就只影响一个位置。设计时统一采用从一开始计数的行列坐标,屏幕标尺与业务数据一致,避免界面显示第一行而数据存成零行的混乱。

字模标识不能直接使用数组下标。字库未来可能增加异体字、印刷方向或不同字重,下标一旦重新排序就会失效。这里给六枚字模分配稳定的字符串标识,字盘的每个格子保存被引用的字模标识,而不是将整份字模对象复制进去。字库信息负责解释“字是什么”,格位信息负责解释“字在哪里”,两者职责清晰。

interface GlyphDef {
  id: string;
  text: string;
}
interface PrintSlot {
  key: string;
  row: number;
  col: number;
  glyphId: string;
}
const GLYPHS: GlyphDef[] = [
  { id: 'shan', text: '山' }, { id: 'hai', text: '海' },
  { id: 'yun', text: '云' }, { id: 'feng', text: '风' },
  { id: 'yue', text: '月' }, { id: 'guang', text: '光' }
];
function createBoard(): PrintSlot[] {
  const result: PrintSlot[] = [];
  for (let row = 1; row <= 4; row++) {
    for (let col = 1; col <= 6; col++) {
      const index: number = (row - 1) * 6 + col - 1;
      result.push({ key: `${row}:${col}`, row: row, col: col,
        glyphId: row === 1 ? GLYPHS[index].id : '' });
    }
  }
  return result;
}

这份创建逻辑始终生成二十四格,不会因为没有内容就缩短数组。初始化仅第一行引用字库,顺序是“山海云风月光”。使用row:col生成键,第一行第二列即1:2;后续编辑只更新该键对应项,不需要重排其他空格。对于固定六列的初版印板,索引公式可以反算位置,但后续扩展列数时应该读取格位本身保存的行列,而不是假定旧索引永远有效。

选中与落字采用两次明确操作

“点格子”只改变焦点;“点字库”只改变准备取用的字模;“置入”才真正修改板面。这种分工使误触可以在提交之前得到纠正,也为撤回、批量操作留下明确的事务入口。选中第一行第二列不会自动替换原来的“海”,预览区能够同时告诉使用者当前格中是什么字、准备放入什么字。

更新采用返回新数组的纯函数,让页面状态在一次赋值中完成更新。位置键不存在、字模ID不在字库中都应拒绝改变;空格可被主动清除,但清除与点击空位应是两种不同动作。这样错误输入不会产生一格不知来源的文字,下一阶段添加字模复用时也不会破坏格位身份。

function hasGlyph(id: string): boolean {
  return id === '' || GLYPHS.some((item: GlyphDef) => item.id === id);
}
function placeGlyph(slots: PrintSlot[], key: string,
  glyphId: string): PrintSlot[] {
  if (!hasGlyph(glyphId) || !slots.some((slot: PrintSlot) => slot.key === key)) {
    return slots;
  }
  return slots.map((slot: PrintSlot) => {
    if (slot.key !== key) {
      return slot;
    }
    return { key: slot.key, row: slot.row, col: slot.col, glyphId: glyphId };
  });
}
function readGlyph(id: string): string {
  const found: GlyphDef | undefined = GLYPHS.find(
    (item: GlyphDef) => item.id === id);
  return found ? found.text : '';
}

这段函数不修改调用方传入的数组。没有匹配的键时,返回原数组即可让UI保持原状;传入不认识的字模ID时,也保持既有布局。业务上的清空格位使用合法的空字符串,而不是从数组中删除格位。存储时若遇到未知字模ID,应该在读取环节单独记录数据问题,避免把未知字误认成可编辑的空位。

ArkUI二维网格绑定固定格位

ArkUI的Grid与GridItem适合本页的定长二维排布。列模板显式给出六列,行模板给出四行,确保二十四个GridItem都有稳定位置。这里列表不是按字库渲染,而是按全部格位渲染,所以十八个空格能保留边框与点击区域。ForEach中的key选择格位键而非数组下标,后续替换字模不改变组件的逻辑身份。

@Entry
@Component
struct PrintBoardPage {
  @State private slots: PrintSlot[] = createBoard();
  @State private selectedKey: string = '1:2';
  @State private activeGlyphId: string = 'hai';

  build() {
    Column({ space: 12 }) {
      Text('活字字盘 · 六字印板').fontSize(23).fontWeight(FontWeight.Bold)
      Text('4行×6列 · 选中第1行第2列')
      Grid() {
        ForEach(this.slots, (slot: PrintSlot) => {
          GridItem() {
            Text(readGlyph(slot.glyphId))
              .fontSize(28).textAlign(TextAlign.Center)
              .width('100%').height('100%')
          }
          .backgroundColor(this.selectedKey === slot.key ? '#DDEEFE' : '#F8F1DF')
          .border({ width: this.selectedKey === slot.key ? 2 : 1,
            color: this.selectedKey === slot.key ? '#237CDB' : '#C7B99D' })
          .onClick(() => { this.selectedKey = slot.key; })
        }, (slot: PrintSlot) => slot.key)
      }
      .columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr')
      .rowsTemplate('1fr 1fr 1fr 1fr')
      .width(336).height(224)
      Button('将选中字模置入').onClick(() => {
        this.slots = placeGlyph(this.slots, this.selectedKey, this.activeGlyphId);
      })
    }.padding(16).width('100%')
  }
}

这里给格位设置固定高度不是字模自身的尺寸,而是保证每个格子都可准确命中。若页面可用宽度不足三百三十六vp,需要由外层容器调整网格整体宽度与单元大小,不能只缩小字而保留超宽点击区域。文本作为GridItem子内容时应居中呈现,焦点高亮放在格位边框上,避免通过修改字模颜色来间接表达选中状态。

字盘数据与ArkUI网格的开发环境

统计结果要从格位计算

页面头部的“已排字六格、空位十八格”来自格位内容,而不是单独维护一个自增计数器。每次置入前如果当前位置本来已经有字,把计数简单加一会立刻出现重复;替换“海”为“云”时,已占用数量其实没有变化。采用每次从二十四个格位筛选非空项,计算量固定且很低,数据更容易保持一致。

对字模的选择也有两个不同指标。字库总量始终是六枚;印板占用数量可能从六变成七;某一字模在印板上可以被引用多次。初版不需要为这三个指标分别建立可修改状态,它们都能从同一份字库和格位集合推导。后续当页面增加多印板和字号时,这样的统计来源更便于核对。

手机字盘中的第一行六字与空位布局

输入反馈与误操作恢复

字盘有两种强调色:蓝色边框表示当前待编辑格位,浅色底纹表示已放入文字。两者不能混用,否则移动焦点时会让使用者误判印板内容发生变化。建议在字库区同时显示准备取用的字,按钮文字维持“将选中字模置入”,而不是一个没有对象的“确定”。放入相同字模属于重复写入,业务上可以返回原数组,避免产生无价值的历史操作;替换成另一个字模则要明确保留格位键,并允许后续撤销。

触控区域至少覆盖整个格子;空格也应能够被键盘导航和辅助功能定位。可将“第几行、第几列、当前字、是否选中”等信息作为格位语义描述,空位读作“空格”,不要使用没有意义的空字符串提示。界面焦点和可编辑焦点在复杂场景下并不总是相同,尤其当字库与格盘都能被选中时,应由页面状态明确记录两者,避免组件重新创建后丢失准备置入的字模。

边界场景与下一阶段接口

需要重点覆盖的输入包括第一格、最后一格、空位、已有字位,以及不存在的格位键。对于第一行第二列,选中后再次点击仍应保留1:2;选择字库中的“月”但未点击置入时,板面仍然是“山海云风月光”。对第二行第一列执行置入则由空变有字,已占用数量增加一,而原来的六个格位不移动。

页面切换或组件重建时,业务状态应由上层持有或从可信的本地记录加载。不要依靠GridItem内部短暂的高亮值推断实际选中键。长期保存时建议同时记录印板规格、字库版本和二十四个完整格位,这样读取时能检查长度和重复键,遇到错误数据也有明确的处理入口。

华为开发者文档中的Grid/GridItem网格说明了固定行列模板及ForEach子项用法;状态变化通知可作为后续观察板面摘要的参考。当前页以显式状态赋值为主,避免把数据写回藏在界面刷新过程里。

第一篇沉淀的不是一张静态的字盘,而是一套可扩展的数据关系:字模有稳定身份,格位有稳定身份,空位始终存在,选中与落字独立发生。第二篇将沿用这二十四个格位和六枚字模,在第二行加入重复引用,并为每个放置位置选择独立的字号级别。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐